From ebad4cdfd08b1ee00c2d50c35058045e78658521 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 27 May 2026 12:54:19 -0400 Subject: [PATCH 1/8] refactor(jetbrains): share session part chrome --- .../client/session/model/SessionModel.kt | 2 +- .../client/session/views/ReasoningView.kt | 218 +++------- .../kilocode/client/session/views/ToolView.kt | 385 ++++++++---------- .../client/session/views/ViewFactory.kt | 3 + .../views/base/AbstractSessionPartView.kt | 139 +++++++ .../client/session/views/base/GenericView.kt | 13 +- .../views/base/PrimarySessionPartView.kt | 25 ++ .../views/base/SecondarySessionPartView.kt | 22 + .../session/views/todo/TodoWriteView.kt | 183 +++------ .../client/session/model/SessionModelTest.kt | 15 +- .../client/session/ui/SessionUiUpdateTest.kt | 8 + .../client/session/views/ReadToolViewTest.kt | 58 +++ .../client/session/views/ReasoningViewTest.kt | 3 + .../client/session/views/ToolViewTest.kt | 19 - .../views/base/AbstractSessionPartViewTest.kt | 55 +++ .../session/views/todo/TodoWriteViewTest.kt | 7 +- 16 files changed, 619 insertions(+), 536 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt 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 79bf8eba3db..5b356d0ff5e 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 @@ -35,7 +35,7 @@ class SessionModel { companion object { /** Part types that are internal server markers and must never be stored or rendered. */ - val SILENT_PART_TYPES = setOf("step-start") + val SILENT_PART_TYPES = setOf("step-start", "patch") } private val entries = LinkedHashMap() 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 7faf0e3ac9b..fe7ed35b31a 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 @@ -6,8 +6,8 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.session.views.base.PartView 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.md.MdView import com.intellij.icons.AllIcons @@ -15,101 +15,30 @@ import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI import java.awt.BorderLayout -import java.awt.Cursor import java.awt.Dimension import java.awt.Font import java.awt.Rectangle -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent import javax.swing.JPanel import javax.swing.ScrollPaneConstants import javax.swing.Scrollable -import javax.swing.SwingUtilities -/** Renders reasoning as a VS Code-style collapsible block. */ -class ReasoningView(reasoning: Reasoning) : PartView() { +/** Renders reasoning as a secondary collapsible block. */ +class ReasoningView(reasoning: Reasoning, private val parts: ReasoningParts = reasoningParts()) : + SecondarySessionPartView(parts.header, parts.scroll) { override val contentId: String = reasoning.id - val md: MdView = MdView.html() - - private val arrow = JBLabel() - private val body = TrackPanel().apply { - isOpaque = true - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - } - private val scroll = JBScrollPane(body).apply { - border = SessionUiStyle.View.cardTop() - isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED - } - private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { - isOpaque = true - background = SessionUiStyle.View.header() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - } - private val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { - foreground = UiStyle.Colors.weak() - } - private val icon = JBLabel(AllIcons.General.InspectionsEye).apply { - foreground = UiStyle.Colors.weak() - } + val md: MdView = parts.md private var style = SessionEditorStyle.current() private var source = reasoning.content.toString() - private val click = object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (!canExpand()) return - toggle() - } - } - - private val mouse = object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { - setHover(true) - } - - override fun mouseExited(e: MouseEvent) { - if (inside(e)) return - setHover(false) - } - } - init { - layout = BorderLayout() - isOpaque = false - border = SessionUiStyle.View.card() - - val left = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { - isOpaque = false - add(icon, BorderLayout.WEST) - add(title, BorderLayout.CENTER) - } - - header.add(left, BorderLayout.CENTER) - header.add(arrow, BorderLayout.EAST) - listOf(header, left, title, icon, arrow).forEach { - it.addMouseListener(click) - it.addMouseListener(mouse) - } - - applyStyle(SessionEditorStyle.current()) + bindHeader(parts.title, parts.icon) + applyStyle(style) md.opaque = false md.set(source) - body.add(md.component, BorderLayout.CENTER) - - add(header, BorderLayout.NORTH) + parts.panel.add(md.component, BorderLayout.CENTER) sync() } @@ -135,106 +64,35 @@ class ReasoningView(reasoning: Reasoning) : PartView() { } fun markdown(): String = source - - fun isExpanded(): Boolean = bodyVisible() - fun hasToggle(): Boolean = arrow.isVisible - - fun headerText(): String = title.text - - internal fun headerFont() = title.font - - internal fun bodyVisible() = scroll.parent === this - - internal fun horizontalPolicy() = scroll.horizontalScrollBarPolicy - + fun headerText(): String = parts.title.text + internal fun headerFont() = parts.title.font + internal fun bodyVisible() = parts.scroll.parent === this + internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy internal fun bodyMaxRows() = SessionUiStyle.View.Reasoning.BODY_LINES - internal fun bodyCreated() = true override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false - if (title.font != style.smallEditorFont) { - title.font = style.smallEditorFont + if (parts.title.font != style.smallEditorFont) { + parts.title.font = style.smallEditorFont changed = true } changed = apply(md) || changed if (changed) refresh() } - fun toggle() { - if (!canExpand()) return - var changed = if (bodyVisible()) collapse() else expand() - changed = sync() || changed - if (changed) refresh() - } - override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size - val height = header.preferredSize.height + bodyMaxHeight() + val height = row.preferredSize.height + bodyMaxHeight() return Dimension(size.width, minOf(size.height, height)) } - private fun setHover(value: Boolean) { - val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() - if (header.background?.rgb == color.rgb) return - header.background = color - header.repaint() - } - - private fun inside(e: MouseEvent): Boolean { - val point = SwingUtilities.convertPoint(e.component, e.point, header) - return header.contains(point) - } - private fun canExpand(): Boolean = source.isNotBlank() - private fun sync(): Boolean { - val expand = canExpand() - if (!expand) collapse() - var changed = false - changed = setVisible(arrow, expand) || changed - changed = syncArrow() || changed - val cursor = if (expand) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() - listOf(header, title, icon, arrow).forEach { - if (it.cursor?.type != cursor.type) { - it.cursor = cursor - changed = true - } - } - return changed - } - - private fun setVisible(component: JBLabel, visible: Boolean): Boolean { - if (component.isVisible == visible) return false - component.isVisible = visible - return true - } - - private fun setIcon(label: JBLabel, icon: javax.swing.Icon): Boolean { - if (label.icon === icon) return false - label.icon = icon - return true - } - - private fun syncArrow(): Boolean { - val icon = if (bodyVisible()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight - return setIcon(arrow, icon) - } - - private fun expand(): Boolean { - if (bodyVisible()) return false - add(scroll, BorderLayout.CENTER) - return true - } - - private fun collapse(): Boolean { - val attached = scroll.parent === this - if (attached) remove(scroll) - return attached - } + private fun sync(): Boolean = syncExpandable(canExpand()) private fun apply(md: MdView): Boolean { var changed = false @@ -248,11 +106,6 @@ class ReasoningView(reasoning: Reasoning) : PartView() { return changed } - private fun refresh() { - revalidate() - repaint() - } - private fun bodyMaxHeight(): Int = md.component.getFontMetrics(md.font).height * bodyMaxRows() + JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT) @@ -262,7 +115,44 @@ class ReasoningView(reasoning: Reasoning) : PartView() { } } -private class TrackPanel : JPanel(BorderLayout()), Scrollable { +class ReasoningParts( + val md: MdView, + val panel: TrackPanel, + val scroll: JBScrollPane, + val header: JPanel, + val title: JBLabel, + val icon: JBLabel, +) + +private fun reasoningParts(): ReasoningParts { + val md = MdView.html() + val panel = TrackPanel().apply { + isOpaque = true + background = SessionUiStyle.View.surface() + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), + ) + } + val scroll = JBScrollPane(panel).apply { + border = SessionUiStyle.View.cardTop() + isOpaque = true + background = SessionUiStyle.View.surface() + viewport.background = SessionUiStyle.View.surface() + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() } + val icon = JBLabel(AllIcons.General.InspectionsEye).apply { foreground = UiStyle.Colors.weak() } + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { + isOpaque = false + add(icon, BorderLayout.WEST) + add(title, BorderLayout.CENTER) + } + return ReasoningParts(md, panel, scroll, header, title, icon) +} + +class TrackPanel : JPanel(BorderLayout()), Scrollable { override fun getScrollableTracksViewportWidth() = true override fun getScrollableTracksViewportHeight() = false override fun getPreferredScrollableViewportSize(): Dimension = preferredSize 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 2cb3a4f30b4..d569d5e6590 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 @@ -7,8 +7,9 @@ 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.style.SessionEditorStyle -import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle +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.JBLabel @@ -18,120 +19,34 @@ import com.intellij.util.ui.JBUI import com.intellij.xml.util.XmlStringUtil import java.awt.BorderLayout import java.awt.Color -import java.awt.Component -import java.awt.Cursor import java.awt.Dimension import java.awt.Font -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent import javax.swing.Box import javax.swing.Icon import javax.swing.JComponent import javax.swing.JPanel import javax.swing.ScrollPaneConstants -import javax.swing.SwingUtilities -/** Renders tool calls with VS Code-inspired rows/cards. */ -class ToolView(tool: Tool) : PartView() { +/** Renders non-read tool calls with VS Code-inspired rows/cards. */ +class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : + PrimarySessionPartView(parts.header, parts.scroll) { override val contentId: String = tool.id private var item = tool private var style = SessionEditorStyle.current() - private val root = JPanel(BorderLayout()).apply { - isOpaque = true - background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.card() - } - private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { - isOpaque = true - background = SessionUiStyle.View.header() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - } - private val glyph = JBLabel() - private val title = JBLabel() - private val sub = JBLabel().apply { - foreground = UiStyle.Colors.weak() - } - private val state = JBLabel().apply { - foreground = UiStyle.Colors.weak() - } - private val arrow = JBLabel() - private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { - isOpaque = false - } - private val controls: JComponent = Box.createHorizontalBox().apply { - add(state) - add(arrow) - } - private val text = JBTextArea().apply { - isEditable = false - caret.isVisible = false - caret.isSelectionVisible = false - lineWrap = true - wrapStyleWord = true - foreground = bodyColor() - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - } - private val scroll = JBScrollPane(text).apply { - border = SessionUiStyle.View.cardTop() - isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED - } - - private val click = object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (!canExpand(item)) return - toggle() - } - } - - private val mouse = object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { - setHover(true) - } - - override fun mouseExited(e: MouseEvent) { - if (inside(e)) return - setHover(false) - } - } - init { - layout = BorderLayout() - isOpaque = false - center.add(title, BorderLayout.WEST) - center.add(sub, BorderLayout.CENTER) - header.add(glyph, BorderLayout.WEST) - header.add(center, BorderLayout.CENTER) - header.add(controls, BorderLayout.EAST) - root.add(header, BorderLayout.NORTH) - - listOf(header, glyph, title, sub, state, arrow, center, controls).forEach { - bind(it) - it.addMouseListener(click) - } - text.text = preview(item) - applyStyle(SessionEditorStyle.current()) - add(root, BorderLayout.CENTER) + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls) + parts.text.text = preview(item) + applyStyle(style) sync() } override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size - val height = header.preferredSize.height + bodyMaxHeight() + val height = row.preferredSize.height + bodyMaxHeight() return Dimension(size.width, minOf(size.height, height)) } @@ -140,164 +55,226 @@ class ToolView(tool: Tool) : PartView() { val was = item.name item = content var changed = false - if (was != content.name || !canExpand(content)) changed = detach() || changed + if (was != content.name || !canExpand(content)) changed = collapse() || changed changed = sync() || changed changed = syncBody() || changed if (changed) refresh() } - fun labelText(): String = listOf(title.text, sub.text, state.text).filter { it.isNotBlank() }.joinToString(" ") + fun labelText(): String = listOf(parts.title.text, parts.sub.text, parts.state.text).filter { it.isNotBlank() }.joinToString(" ") fun commandText(): String = command(item) fun outputText(): String = output(item) - fun bodyText(): String = body(item) - - internal fun previewText(): String = text.text - - fun isExpanded(): Boolean = bodyVisible() - + internal fun previewText(): String = parts.text.text fun hasToggle(): Boolean = arrow.isVisible - - internal fun bodyFont() = text.font - - internal fun titleFont() = title.font - - internal fun subtitleFont() = sub.font - - internal fun stateFont() = state.font - - internal fun bodyEditable() = text.isEditable - - internal fun bodyCaretVisible() = text.caret.isVisible - - internal fun bodyVisible() = scroll.parent === root - + internal fun bodyFont() = parts.text.font + internal fun titleFont() = parts.title.font + internal fun subtitleFont() = parts.sub.font + internal fun stateFont() = parts.state.font + internal fun bodyEditable() = parts.text.isEditable + internal fun bodyCaretVisible() = parts.text.caret.isVisible + internal fun bodyVisible() = parts.scroll.parent === this internal fun controlCount() = if (arrow.isVisible) 1 else 0 - - internal fun horizontalPolicy() = scroll.horizontalScrollBarPolicy - - internal fun bodyWrap() = text.lineWrap - + internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy + internal fun bodyWrap() = parts.text.lineWrap internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES - internal fun bodyCreated() = true override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false - changed = setFont(title, style.boldEditorFont) || changed - changed = setFont(sub, style.smallEditorFont) || changed - changed = setFont(state, style.smallEditorFont) || changed - changed = setFont(text, style.transcriptFont) || changed + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.smallEditorFont) || changed + changed = setFont(parts.state, style.smallEditorFont) || changed + changed = setFont(parts.text, style.transcriptFont) || changed if (changed) refresh() } - fun toggle() { - if (!canExpand(item)) return - var changed = if (bodyVisible()) detach() else attach() - changed = syncArrow() || changed - if (changed) refresh() - } - - private fun setHover(value: Boolean) { - val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() - if (same(header.background, color)) return - header.background = color - header.repaint() - } - - private fun inside(e: MouseEvent): Boolean { - val point = SwingUtilities.convertPoint(e.component, e.point, header) - return header.contains(point) - } - - private fun bind(component: Component) { - component.addMouseListener(mouse) - } - private fun sync(): Boolean { val expand = canExpand(item) - val cursor = if (expand) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() var changed = false - changed = syncCursor(cursor) || changed - changed = setVisible(arrow, expand) || changed - changed = setVisible(state, !expand) || changed - changed = syncArrow() || changed + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, !expand) || changed changed = syncLabels() || changed - changed = setForeground(text, bodyColor()) || changed + changed = setForeground(parts.text, bodyColor()) || changed return changed } private fun syncLabels(): Boolean { var changed = false - changed = setIcon(glyph, icon(item)) || changed - changed = setForeground(glyph, color(item)) || changed - changed = setText(title, title(item)) || changed - changed = setText(sub, subtitle(item)) || changed - changed = setForeground(title, titleColor(item)) || changed - changed = setText(state, stateText(item)) || changed - changed = setForeground(state, color(item)) || 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 = setForeground(parts.title, titleColor(item)) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed return changed } - private fun syncArrow(): Boolean { - val icon = if (bodyVisible()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight - return setIcon(arrow, icon) - } - private fun syncBody(): Boolean { var changed = false val value = preview(item) - if (text.text != value) { - text.text = value - text.caretPosition = 0 + if (parts.text.text != value) { + parts.text.text = value + parts.text.caretPosition = 0 changed = true } - changed = setForeground(text, bodyColor()) || changed + changed = setForeground(parts.text, bodyColor()) || changed return changed } - private fun attach(): Boolean { - if (bodyVisible()) return false - syncBody() - root.add(scroll, BorderLayout.CENTER) - return true - } - - private fun detach(): Boolean { - val attached = scroll.parent === root - if (attached) root.remove(scroll) - return attached - } - - private fun syncCursor(cursor: Cursor): Boolean { - var changed = false - listOf(header, glyph, title, sub, state, arrow, center, controls).forEach { - if (it.cursor?.type != cursor.type) { - it.cursor = cursor - changed = true - } - } - return changed - } - - private fun refresh() { - revalidate() - repaint() - } - private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() private fun bodyMaxHeight(): Int { - return text.getFontMetrics(text.font).height * bodyMaxRows() + + return parts.text.getFontMetrics(parts.text.font).height * bodyMaxRows() + JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT) } override fun dumpLabel() = "ToolView#$contentId(${labelText()})" } +/** Renders read calls with secondary, borderless chrome. */ +class ReadToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : + SecondarySessionPartView(parts.header, parts.scroll) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "read" + } + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + + init { + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls) + parts.text.text = preview(item) + applyStyle(style) + sync() + } + + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + override fun update(content: Content) { + if (content !is Tool) return + item = content + var changed = sync() + changed = syncBody() || changed + if (changed) refresh() + } + + fun labelText(): String = listOf(parts.title.text, parts.sub.text, 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 + + 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 + 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 = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, title(item)) || changed + changed = setText(parts.sub, subtitle(item)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + changed = setForeground(parts.text, bodyColor()) || changed + return changed + } + + private fun syncBody(): Boolean { + val value = preview(item) + if (parts.text.text == value) return false + parts.text.text = value + parts.text.caretPosition = 0 + return true + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + return parts.text.getFontMetrics(parts.text.font).height * bodyMaxRows() + + JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "ReadToolView#$contentId(${labelText()})" +} + +class ToolParts( + val header: JPanel, + val glyph: JBLabel, + val title: JBLabel, + val sub: JBLabel, + val state: JBLabel, + val center: JPanel, + val controls: JComponent, + val text: JBTextArea, + val scroll: JBScrollPane, +) + +private fun toolParts(tool: Tool): ToolParts { + val glyph = JBLabel() + val title = JBLabel() + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + 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 { + isEditable = false + caret.isVisible = false + caret.isSelectionVisible = false + lineWrap = true + wrapStyleWord = true + foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + background = SessionUiStyle.View.surface() + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), + ) + } + val scroll = JBScrollPane(text).apply { + border = SessionUiStyle.View.cardTop() + isOpaque = true + background = SessionUiStyle.View.surface() + viewport.background = SessionUiStyle.View.surface() + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + val controls = Box.createHorizontalBox() + 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) + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + return ToolParts(header, glyph, title, sub, state, center, controls, text, scroll).also { + controls.add(it.state) + } +} + private fun icon(tool: Tool) = when (tool.name) { "read" -> AllIcons.Actions.Preview "bash" -> AllIcons.Debugger.Console @@ -453,9 +430,7 @@ private fun plainBody(tool: Tool): String { } private fun canExpand(tool: Tool): Boolean { - if (tool.name == "bash") { - return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank() - } + if (tool.name == "bash") return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank() return output(tool).isNotBlank() || !tool.error.isNullOrBlank() } 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 5ee3c959cd7..5074a4cbf5d 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,6 +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) else -> ToolView(content) } is Compaction -> CompactionView(content) @@ -47,6 +48,8 @@ object ViewFactory { if (view is PlanExitView) return !PlanExitView.canRender(content) if (view !is PlanExitView && PlanExitView.canRender(content)) return true if (view is QuestionResultView) return !QuestionResultView.canRender(content) + if (view is ReadToolView) return !ReadToolView.canRender(content) || QuestionResultView.canRender(content) + if (view is ToolView && ReadToolView.canRender(content)) return true if (view is ToolView) return QuestionResultView.canRender(content) return false } 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 new file mode 100644 index 00000000000..83062e227c1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -0,0 +1,139 @@ +package ai.kilocode.client.session.views.base + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component +import java.awt.Cursor +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingUtilities + +abstract class AbstractSessionPartView( + header: JComponent, + protected val body: JComponent, + expanded: Boolean = false, +) : PartView() { + + protected val arrow = JBLabel() + protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)) + private val bound = mutableListOf() + + private val click = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (!arrow.isVisible) return + toggle() + } + } + private val mouse = object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + setHover(true) + } + + override fun mouseExited(e: MouseEvent) { + if (inside(e)) return + setHover(false) + } + } + + init { + layout = BorderLayout() + isOpaque = false + row.add(header, BorderLayout.CENTER) + row.add(arrow, BorderLayout.EAST) + add(row, BorderLayout.NORTH) + bindHeader(row, header, arrow) + if (expanded) add(body, BorderLayout.CENTER) + syncArrow() + } + + fun isExpanded(): Boolean = body.parent === this + + fun toggle() { + if (!arrow.isVisible) return + val changed = if (isExpanded()) collapse() else expand() + if (!changed) return + syncArrow() + refresh() + } + + fun expand(): Boolean { + if (isExpanded()) return false + add(body, BorderLayout.CENTER) + return true + } + + fun collapse(): Boolean { + if (!isExpanded()) return false + remove(body) + return true + } + + 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 moved = syncCursor(cursor) + val icon = syncArrow() + return changed || detached || moved || icon + } + + protected fun bindHeader(vararg items: Component) { + items.forEach { bind(it) } + } + + protected fun refresh() { + revalidate() + repaint() + } + + protected open fun hoverColor(value: Boolean): Color? = null + + private fun setHover(value: Boolean) { + val color = hoverColor(value) ?: return + if (row.background?.rgb == color.rgb) return + row.background = color + row.repaint() + } + + private fun inside(e: MouseEvent): Boolean { + val point = SwingUtilities.convertPoint(e.component, e.point, row) + return row.contains(point) + } + + private fun bind(component: Component) { + if (bound.contains(component)) return + bound.add(component) + component.addMouseListener(click) + component.addMouseListener(mouse) + } + + private fun syncCursor(cursor: Cursor): Boolean { + var changed = false + bound.forEach { + if (it.cursor?.type != cursor.type) { + it.cursor = cursor + changed = true + } + } + return changed + } + + private fun syncArrow(): Boolean { + val icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + if (arrow.icon === icon) return false + arrow.icon = icon + return true + } + + private fun setVisible(component: JComponent, visible: Boolean): Boolean { + if (component.isVisible == visible) return false + component.isVisible = visible + return true + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt index 0dcfb05e209..18c94fd1878 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt @@ -5,7 +5,6 @@ import ai.kilocode.client.session.model.Generic import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.ui.UiStyle import com.intellij.ui.components.JBLabel -import java.awt.BorderLayout /** * Fallback renderer for part types that have no dedicated view. @@ -14,20 +13,16 @@ import java.awt.BorderLayout * confusing empty gaps), this shows a dim label with the raw type name. * This makes it easy to spot new part types that need a proper renderer. */ -class GenericView(content: Generic) : PartView() { +class GenericView(content: Generic) : PrimarySessionPartView(JBLabel("[${content.type}]"), JBLabel()) { override val contentId: String = content.id - private val label = JBLabel("[${content.type}]").apply { - foreground = UiStyle.Colors.weak() - border = com.intellij.util.ui.JBUI.Borders.empty(UiStyle.Gap.xs(), 0) - } + private val label = row.getComponent(0) as JBLabel init { - layout = BorderLayout() - isOpaque = false + label.foreground = UiStyle.Colors.weak() applyStyle(SessionEditorStyle.current()) - add(label, BorderLayout.CENTER) + syncExpandable(false) } override fun update(content: Content) {} // generic content has no updatable state diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt new file mode 100644 index 00000000000..aeae970bfb3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt @@ -0,0 +1,25 @@ +package ai.kilocode.client.session.views.base + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.util.ui.JBUI +import javax.swing.JComponent + +abstract class PrimarySessionPartView( + header: JComponent, + content: JComponent, + expanded: Boolean = false, +) : AbstractSessionPartView(header, content, expanded) { + init { + isOpaque = true + background = SessionUiStyle.View.surface() + border = SessionUiStyle.View.card() + row.isOpaque = true + row.background = SessionUiStyle.View.header() + row.border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), + ) + } + + override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() +} 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 new file mode 100644 index 00000000000..af191dc100b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt @@ -0,0 +1,22 @@ +package ai.kilocode.client.session.views.base + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.util.ui.JBUI +import javax.swing.JComponent + +abstract class SecondarySessionPartView( + header: JComponent, + content: JComponent, + expanded: Boolean = false, +) : AbstractSessionPartView(header, content, expanded) { + init { + row.isOpaque = true + row.background = SessionUiStyle.View.header() + row.border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), + ) + } + + override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() +} 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 e1a57c26248..94efdd1fc6a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -6,89 +6,32 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.BorderLayout -import java.awt.Color -import java.awt.Component -import java.awt.Cursor import java.awt.Font -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent import javax.swing.Box import javax.swing.JComponent import javax.swing.JPanel -import javax.swing.SwingUtilities -class TodoWriteView(tool: Tool) : PartView() { +class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : + PrimarySessionPartView(parts.header, parts.list, expanded = true) { override val contentId = tool.id private var item = tool private var style = SessionEditorStyle.current() - private val root = JPanel(BorderLayout()).apply { - isOpaque = true - background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.card() - } - private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { - isOpaque = true - background = SessionUiStyle.View.header() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - } - private val glyph = JBLabel(AllIcons.Actions.Checked) - private val title = JBLabel(KiloBundle.message("session.part.todo.title")) - private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - private val arrow = JBLabel(AllIcons.General.ArrowDown) - private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { - isOpaque = false - } - private val controls: JComponent = Box.createHorizontalBox().apply { add(arrow) } - private val list = TodoListPanel() - - private val click = object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - toggle() - } - } - private val mouse = object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { - setHover(true) - } - - override fun mouseExited(e: MouseEvent) { - if (inside(e)) return - setHover(false) - } - } - init { - layout = BorderLayout() - isOpaque = false - center.add(title, BorderLayout.WEST) - center.add(sub, BorderLayout.CENTER) - header.add(glyph, BorderLayout.WEST) - header.add(center, BorderLayout.CENTER) - header.add(controls, BorderLayout.EAST) - root.add(header, BorderLayout.NORTH) - root.add(list, BorderLayout.CENTER) - list.border = JBUI.Borders.compound( + bindHeader(parts.glyph, parts.title, parts.sub, parts.center, parts.controls) + parts.list.border = JBUI.Borders.compound( SessionUiStyle.View.cardTop(), JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()), ) - listOf(header, glyph, title, sub, arrow, center, controls).forEach { - bind(it) - it.addMouseListener(click) - } applyStyle(style) - add(root, BorderLayout.CENTER) sync() } @@ -101,98 +44,72 @@ class TodoWriteView(tool: Tool) : PartView() { override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false - changed = setFont(title, style.boldEditorFont) || changed - changed = setFont(sub, style.transcriptFont) || changed - list.applyStyle(style) + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.transcriptFont) || changed + parts.list.applyStyle(style) if (changed) refresh() } - fun toggle() { - val changed = if (isExpanded()) detach() else attach() - if (!changed) return - syncArrow() - refresh() - } - - fun isExpanded() = list.parent === root - - fun labelText(): String = listOf(title.text, sub.text).filter { it.isNotBlank() }.joinToString(" ") - - internal fun rowCount() = list.rowCount() - - internal fun rowText(index: Int) = list.rowText(index) - - internal fun rowChecked(index: Int) = list.rowChecked(index) - - internal fun rowCheckboxOpaque(index: Int) = list.rowCheckboxOpaque(index) - - internal fun rowForeground(index: Int) = list.rowForeground(index) - - internal fun hiddenText() = list.hiddenText() - - internal fun titleFont() = title.font - - internal fun subtitleFont() = sub.font + fun labelText(): String = listOf(parts.title.text, parts.sub.text).filter { it.isNotBlank() }.joinToString(" ") + internal fun rowCount() = parts.list.rowCount() + internal fun rowText(index: Int) = parts.list.rowText(index) + internal fun rowChecked(index: Int) = parts.list.rowChecked(index) + internal fun rowCheckboxOpaque(index: Int) = parts.list.rowCheckboxOpaque(index) + internal fun rowForeground(index: Int) = parts.list.rowForeground(index) + internal fun hiddenText() = parts.list.hiddenText() + internal fun titleFont() = parts.title.font + internal fun subtitleFont() = parts.sub.font override fun dumpLabel() = "TodoWriteView#$contentId(${labelText()})" private fun sync() { - sub.text = subtitle(item) + parts.sub.text = subtitle(item) val view = item.todoView val compact = view?.mode == "compact" val rows = if (compact) view.todos else item.todos - list.update( + parts.list.update( rows, hiddenBefore = if (compact) view.hiddenBefore else 0, hiddenAfter = if (compact) view.hiddenAfter else 0, ) - syncArrow() + syncExpandable(true) refresh() } - private fun attach(): Boolean { - if (isExpanded()) return false - root.add(list, BorderLayout.CENTER) - return true - } - - private fun detach(): Boolean { - if (!isExpanded()) return false - root.remove(list) - return true - } - - private fun syncArrow() { - arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight - } - - private fun setHover(value: Boolean) { - val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() - if (same(header.background, color)) return - header.background = color - header.repaint() - } - - private fun inside(e: MouseEvent): Boolean { - val point = SwingUtilities.convertPoint(e.component, e.point, header) - return header.contains(point) - } - - private fun bind(component: Component) { - component.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - component.addMouseListener(mouse) - } - - private fun refresh() { - revalidate() - repaint() - } - companion object { fun canRender(tool: Tool) = tool.name == "todowrite" && tool.state == ToolExecState.COMPLETED } } +class TodoParts( + val header: JPanel, + val glyph: JBLabel, + val title: JBLabel, + val sub: JBLabel, + val center: JPanel, + val controls: JComponent, + val list: TodoListPanel, +) + +private fun todoParts(): TodoParts { + val glyph = JBLabel(AllIcons.Actions.Checked) + val title = JBLabel(KiloBundle.message("session.part.todo.title")) + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { + isOpaque = false + add(title, BorderLayout.WEST) + add(sub, BorderLayout.CENTER) + } + val controls = Box.createHorizontalBox() + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { + isOpaque = false + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + return TodoParts(header, glyph, title, sub, center, controls, TodoListPanel()) +} + private fun subtitle(tool: Tool): String { val total = tool.todos.size if (total == 0) return "" @@ -205,5 +122,3 @@ private fun setFont(component: JComponent, font: Font): Boolean { component.font = font return true } - -private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb 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 fcdf7586617..a691ddea409 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 @@ -509,18 +509,29 @@ class SessionModelTest : UsefulTestCase() { assertEquals("snapshot", (entry.parts["p2"] as Generic).type) } - fun `test loadHistory drops step-start and preserves step-finish parts`() { + fun `test loadHistory drops silent parts and preserves step-finish parts`() { val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "visible") val stepStart = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "step-start") val stepFinish = PartDto(id = "p3", sessionID = "s1", messageID = "m1", type = "step-finish") + val patch = PartDto(id = "p4", sessionID = "s1", messageID = "m1", type = "patch") - model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, stepStart, stepFinish)))) + model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, stepStart, stepFinish, patch)))) val entry = model.message("m1")!! assertEquals(listOf("p1", "p3"), entry.parts.keys.toList()) assertTrue(entry.parts["p3"] is StepFinish) } + fun `test updateContent drops patch parts`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.updateContent("m1", PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "patch")) + + assertFalse(model.message("m1")!!.parts.containsKey("p1")) + assertTrue(events.isEmpty()) + } + fun `test upsertMessage adds new message and returns true`() { val added = model.upsertMessage(msg("m1", "user")) 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 e030a761883..9178b11163c 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 @@ -87,6 +87,14 @@ class SessionUiUpdateTest : BasePlatformTestCase() { assertFalse(tv.labelText().contains("Running")) } + fun `test read tool renders as ReadToolView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("t1", "a1", "read", "completed")) + + val tv = panel.findMessage("a1")!!.part("t1") + assertTrue(tv is ai.kilocode.client.session.views.ReadToolView) + } + // ------ multiple turns update correctly ------ fun `test content goes to correct turn when multiple turns exist`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt new file mode 100644 index 00000000000..a0aaf6f8bfb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -0,0 +1,58 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class ReadToolViewTest : BasePlatformTestCase() { + + fun `test read tool shows filename`() { + val t = tool().also { it.input = mapOf("filePath" to "README.MD") } + + val view = ReadToolView(t) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + assertTrue(view.labelText().contains("Read")) + assertTrue(view.labelText().contains("README.MD")) + } + + fun `test read tool handles windows path`() { + val t = tool().also { it.input = mapOf("filePath" to "C:\\repo\\README.MD") } + + val view = ReadToolView(t) + + assertTrue(view.labelText().contains("README.MD")) + } + + fun `test read output is secondary collapsible body`() { + val t = tool().also { it.output = "file contents" } + val view = ReadToolView(t) + + assertTrue(view.hasToggle()) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertEquals("file contents", view.bodyText()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + } + + fun `test view factory routes read to read tool view`() { + assertTrue(ViewFactory.create(tool(), openFile = {}) is ReadToolView) + } + + fun `test canRender matches read tools only`() { + assertTrue(ReadToolView.canRender(tool())) + assertFalse(ReadToolView.canRender(Tool("p2", "grep", toolKind("grep")))) + } + + private fun tool() = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt index f0cf4d20169..8a3692a1982 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView import com.intellij.testFramework.fixtures.BasePlatformTestCase import javax.swing.ScrollPaneConstants @@ -10,8 +11,10 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test completed reasoning is collapsed by default`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) + val base: Any = view assertFalse(view.isExpanded()) + assertTrue(base is SecondarySessionPartView) assertEquals("Reasoning", view.headerText()) assertEquals("one\ntwo\nthree\nfour", view.markdown()) assertTrue(view.hasToggle()) 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 1486718c479..6ace5750c4f 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 @@ -57,15 +57,6 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(view.labelText().contains("Shell")) } - fun `test read tool shows filename`() { - val t = tool("p1", "read", ToolExecState.COMPLETED).also { it.input = mapOf("filePath" to "README.MD") } - - val view = ToolView(t) - - assertTrue(view.labelText().contains("Read")) - assertTrue(view.labelText().contains("README.MD")) - } - fun `test bash tool shows subtitle command and output`() { val t = tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "git remote -v", "description" to "View remotes") @@ -197,16 +188,6 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(view.bodyVisible()) } - fun `test read tool handles windows path`() { - val t = tool("p1", "read", ToolExecState.COMPLETED).also { - it.input = mapOf("filePath" to "C:\\repo\\README.MD") - } - - val view = ToolView(t) - - assertTrue(view.labelText().contains("README.MD")) - } - fun `test bash output uses editor font settings`() { val style = SessionEditorStyle.current() val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) 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 new file mode 100644 index 00000000000..1d2524a6431 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -0,0 +1,55 @@ +package ai.kilocode.client.session.views.base + +import ai.kilocode.client.session.model.Content +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import javax.swing.JLabel + +@Suppress("UnstableApiUsage") +class AbstractSessionPartViewTest : BasePlatformTestCase() { + + fun `test collapsed by default`() { + val content = JLabel("body") + val view = TestView(content = content) + + assertFalse(view.isExpanded()) + assertNull(content.parent) + } + + fun `test expanded when requested`() { + val content = JLabel("body") + val view = TestView(content = content, expanded = true) + + assertTrue(view.isExpanded()) + assertSame(view, content.parent) + } + + fun `test toggle reuses content component`() { + val content = JLabel("body") + val view = TestView(content = content) + + view.syncExpandable(true) + view.toggle() + assertSame(view, content.parent) + view.toggle() + assertNull(content.parent) + view.toggle() + assertSame(view, content.parent) + } + + fun `test non expandable hides content`() { + val content = JLabel("body") + val view = TestView(content = content, expanded = true) + + view.syncExpandable(false) + + assertFalse(view.isExpanded()) + assertNull(content.parent) + } + + private class TestView(content: JLabel, expanded: Boolean = false) : + PrimarySessionPartView(JLabel("header"), content, expanded) { + + override val contentId = "test" + override fun update(content: Content) {} + } +} 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 636dbb9f36f..444eda3bfec 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 @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TodoViewDto import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -26,8 +27,10 @@ class TodoWriteViewTest : BasePlatformTestCase() { TodoDto("Next", "pending", "medium"), ) }) + val base: Any = view assertTrue(view.labelText().contains("To-dos")) + assertTrue(base is PrimarySessionPartView) assertTrue(view.labelText().contains("1/2")) assertTrue(view.isExpanded()) assertEquals(2, view.rowCount()) @@ -79,13 +82,13 @@ class TodoWriteViewTest : BasePlatformTestCase() { val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { it.todos = listOf(TodoDto("Old", "pending", "medium")) }) - val root = view.components.single() + val comps = view.components.toList() view.update(tool("todowrite", ToolExecState.COMPLETED).also { it.todos = listOf(TodoDto("New", "completed", "high")) }) - assertSame(root, view.components.single()) + assertEquals(comps, view.components.toList()) assertTrue(view.labelText().contains("1/1")) assertTrue(view.rowChecked(0)) assertTrue(view.rowText(0).contains("New")) From da47b98de318d479927d020a9b810f7017cb47b4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 27 May 2026 13:41:25 -0400 Subject: [PATCH 2/8] fix(jetbrains): refine session card chrome --- .changeset/jetbrains-session-hover.md | 5 ++ .../client/session/ui/style/SessionUiStyle.kt | 17 ++++-- .../kilocode/client/session/views/ToolView.kt | 3 +- .../views/base/AbstractSessionPartView.kt | 3 + .../client/session/views/base/GenericView.kt | 2 +- .../views/base/PrimarySessionPartView.kt | 6 ++ .../views/question/QuestionResultView.kt | 2 + .../client/session/ui/SessionUiUpdateTest.kt | 1 + .../session/views/QuestionResultViewTest.kt | 56 +++++++++++++++++++ .../client/session/views/ReadToolViewTest.kt | 10 +++- .../views/base/AbstractSessionPartViewTest.kt | 56 +++++++++++++++++++ 11 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 .changeset/jetbrains-session-hover.md diff --git a/.changeset/jetbrains-session-hover.md b/.changeset/jetbrains-session-hover.md new file mode 100644 index 00000000000..3ed93f93be5 --- /dev/null +++ b/.changeset/jetbrains-session-hover.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Use a subtler platform hover color for JetBrains session cards and match hovered card borders to it. 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 023756b3071..433ad686227 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 @@ -3,7 +3,6 @@ package ai.kilocode.client.session.ui.style import ai.kilocode.client.ui.UiStyle import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI -import com.intellij.util.ui.JBUI.Borders.customLine import com.intellij.util.ui.UIUtil import java.awt.Color import javax.swing.border.Border @@ -26,7 +25,8 @@ object SessionUiStyle { const val CARD_BODY_EXTRA_HEIGHT = 16 internal const val BORDER_DELTA = 64 - internal const val HOVER_ALPHA = 0.35f + internal const val HOVER_BORDER_ALPHA = 0.18f + internal const val HOVER_FILL_ALPHA = 0.10f /** Creates a visible separator against editor-derived transcript surfaces. */ fun line(): Color = JBColor.lazy { UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) } @@ -35,12 +35,17 @@ object SessionUiStyle { fun header(): Color = UiStyle.Colors.editorBackground() - /** Local hover color for collapsible transcript card headers. */ - fun headerHover(): Color = JBColor.lazy { UiStyle.Colors.blend(header(), line(), HOVER_ALPHA) } + /** Subtle hover fill, softer than the card outline. */ + fun headerHover(): Color = JBColor.lazy { UiStyle.Colors.blend(header(), hoverLine(), HOVER_FILL_ALPHA) } - fun card(): Border = cardBorder() + /** Subtle hover outline, stronger than the hover fill. */ + fun hoverLine(): Color = JBColor.lazy { + UiStyle.Colors.blend(line(), JBUI.CurrentTheme.ActionButton.hoverBackground(), HOVER_BORDER_ALPHA) + } - fun cardBorder(): Border = JBUI.Borders.customLine(line(), 1) + fun card(color: Color = line()): Border = cardBorder(color) + + fun cardBorder(color: Color = line()): Border = JBUI.Borders.customLine(color, 1) fun cardTop(): Border = JBUI.Borders.customLineTop(line()) 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 d569d5e6590..9bf12cdd003 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 @@ -6,6 +6,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.model.ToolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.PrimarySessionPartView @@ -141,7 +142,7 @@ class ReadToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : SecondarySessionPartView(parts.header, parts.scroll) { companion object { - fun canRender(tool: Tool): Boolean = tool.name == "read" + fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ } override val contentId: String = tool.id 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 83062e227c1..f62b2f08313 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 @@ -94,10 +94,13 @@ abstract class AbstractSessionPartView( protected open fun hoverColor(value: Boolean): Color? = null + protected open fun applyHover(value: Boolean, color: Color) {} + private fun setHover(value: Boolean) { val color = hoverColor(value) ?: return if (row.background?.rgb == color.rgb) return row.background = color + applyHover(value, color) row.repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt index 18c94fd1878..6b0c0343f30 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt @@ -13,7 +13,7 @@ import com.intellij.ui.components.JBLabel * confusing empty gaps), this shows a dim label with the raw type name. * This makes it easy to spot new part types that need a proper renderer. */ -class GenericView(content: Generic) : PrimarySessionPartView(JBLabel("[${content.type}]"), JBLabel()) { +class GenericView(content: Generic) : SecondarySessionPartView(JBLabel("[${content.type}]"), JBLabel()) { override val contentId: String = content.id 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 aeae970bfb3..a3f2266f85a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.util.ui.JBUI +import java.awt.Color import javax.swing.JComponent abstract class PrimarySessionPartView( @@ -22,4 +23,9 @@ abstract class PrimarySessionPartView( } override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() + + override fun applyHover(value: Boolean, color: Color) { + border = if (value) SessionUiStyle.View.card(SessionUiStyle.View.hoverLine()) else SessionUiStyle.View.card() + repaint() + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt index e33303ede14..93150bad7a4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt @@ -258,7 +258,9 @@ class QuestionResultView(tool: Tool) : PartView() { val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() if (header.background?.rgb == color.rgb) return header.background = color + root.border = if (value) SessionUiStyle.View.card(SessionUiStyle.View.hoverLine()) else SessionUiStyle.View.card() header.repaint() + root.repaint() } private fun inside(e: MouseEvent): Boolean { 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 9178b11163c..4d0177f5666 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 @@ -135,6 +135,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { assertNotNull(gv) assertTrue(gv is ai.kilocode.client.session.views.base.GenericView) assertTrue((gv as ai.kilocode.client.session.views.base.GenericView).labelText().contains("snapshot")) + assertNull(gv.border) } // ------ silent part types ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index 97182fe0fca..34b6a4c4d3e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -4,8 +4,16 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.question.QuestionResultView import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Component +import java.awt.Container +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import javax.swing.JPanel +import javax.swing.border.Border @Suppress("UnstableApiUsage") class QuestionResultViewTest : BasePlatformTestCase() { @@ -122,6 +130,22 @@ class QuestionResultViewTest : BasePlatformTestCase() { assertFalse("Should be collapsed after second toggle", view.isExpanded()) } + fun `test hover border differs from header fill`() { + val view = QuestionResultView(completedTool( + input = mapOf("questions" to """[{"question":"Q1"}]"""), + metadata = mapOf("answers" to """[["A1"]]"""), + )) + val root = view.node(0) + val header = root.node(0) + + enter(header) + + assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(root.border).rgb) + assertNotSameColor(SessionUiStyle.View.headerHover(), paint(root.border)) + exit(header) + assertEquals(SessionUiStyle.View.line().rgb, paint(root.border).rgb) + } + // ------ view factory routing ------ fun `test view factory uses question result view for completed parsable question tool`() { @@ -237,4 +261,36 @@ class QuestionResultViewTest : BasePlatformTestCase() { private fun runningTool(name: String, id: String = "tp1"): Tool = Tool(id, name, toolKind(name)).apply { state = ToolExecState.RUNNING } + + private fun Container.node(index: Int) = components[index] as JPanel + + private fun enter(component: Component) = event(component, MouseEvent.MOUSE_ENTERED) + + private fun exit(component: Component) = event(component, MouseEvent.MOUSE_EXITED) + + private fun event(component: Component, id: Int) { + component.dispatchEvent(MouseEvent( + component, + id, + System.currentTimeMillis(), + 0, + 1, + 1, + 0, + false, + )) + } + + private fun paint(border: Border): Color { + val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) + val panel = JPanel() + val graphics = image.createGraphics() + border.paintBorder(panel, graphics, 0, 0, image.width, image.height) + graphics.dispose() + return Color(image.getRGB(0, 0), true) + } + + private fun assertNotSameColor(left: Color, right: Color) { + assertFalse("Expected distinct colors but both were ${left.rgb}", left.rgb == right.rgb) + } } 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 a0aaf6f8bfb..2265a838690 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 @@ -45,13 +45,17 @@ class ReadToolViewTest : BasePlatformTestCase() { assertTrue(view.bodyVisible()) } - fun `test view factory routes read to read tool view`() { + fun `test view factory routes read kind tools to read tool view`() { assertTrue(ViewFactory.create(tool(), openFile = {}) is ReadToolView) + assertTrue(ViewFactory.create(Tool("p2", "grep", toolKind("grep")), openFile = {}) is ReadToolView) + assertTrue(ViewFactory.create(Tool("p3", "glob", toolKind("glob")), openFile = {}) is ReadToolView) } - fun `test canRender matches read tools only`() { + fun `test canRender matches read kind tools only`() { assertTrue(ReadToolView.canRender(tool())) - assertFalse(ReadToolView.canRender(Tool("p2", "grep", toolKind("grep")))) + assertTrue(ReadToolView.canRender(Tool("p2", "grep", toolKind("grep")))) + assertTrue(ReadToolView.canRender(Tool("p3", "glob", toolKind("glob")))) + assertFalse(ReadToolView.canRender(Tool("p4", "bash", toolKind("bash")))) } private fun tool() = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } 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 1d2524a6431..430e34bd8ae 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 @@ -1,8 +1,15 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Component +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.border.Border @Suppress("UnstableApiUsage") class AbstractSessionPartViewTest : BasePlatformTestCase() { @@ -46,10 +53,59 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { assertNull(content.parent) } + fun `test header hover is subtler than hover outline`() { + assertNotSameColor(SessionUiStyle.View.headerHover(), SessionUiStyle.View.hoverLine()) + assertNotSameColor(SessionUiStyle.View.headerHover(), SessionUiStyle.View.line()) + } + + fun `test primary card border follows hover color`() { + val view = TestView(content = JLabel("body")) + val row = view.component(0) + + enter(row) + + assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(view.border).rgb) + assertNotSameColor(SessionUiStyle.View.headerHover(), paint(view.border)) + exit(row) + assertEquals(SessionUiStyle.View.line().rgb, paint(view.border).rgb) + } + private class TestView(content: JLabel, expanded: Boolean = false) : PrimarySessionPartView(JLabel("header"), content, expanded) { override val contentId = "test" override fun update(content: Content) {} } + + private fun TestView.component(index: Int): Component = components[index] + + private fun enter(component: Component) = event(component, MouseEvent.MOUSE_ENTERED) + + private fun exit(component: Component) = event(component, MouseEvent.MOUSE_EXITED) + + private fun event(component: Component, id: Int) { + component.dispatchEvent(MouseEvent( + component, + id, + System.currentTimeMillis(), + 0, + 1, + 1, + 0, + false, + )) + } + + private fun paint(border: Border): Color { + val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) + val panel = JPanel() + val graphics = image.createGraphics() + border.paintBorder(panel, graphics, 0, 0, image.width, image.height) + graphics.dispose() + return Color(image.getRGB(0, 0), true) + } + + private fun assertNotSameColor(left: Color, right: Color) { + assertFalse("Expected distinct colors but both were ${left.rgb}", left.rgb == right.rgb) + } } From 01cc96419656f5dc7ba8eea8ae21454829a31e2f Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 27 May 2026 14:08:07 -0400 Subject: [PATCH 3/8] fix(jetbrains): link read file rows --- .changeset/jetbrains-session-hover.md | 2 +- .../kilocode/client/session/views/ToolView.kt | 130 ++++++++++++++++-- .../client/session/views/ViewFactory.kt | 2 +- .../views/base/AbstractSessionPartView.kt | 15 +- .../views/base/PrimarySessionPartView.kt | 3 +- .../views/base/SecondarySessionPartView.kt | 3 +- .../client/session/views/ReadToolViewTest.kt | 50 ++++++- .../views/base/AbstractSessionPartViewTest.kt | 21 ++- 8 files changed, 196 insertions(+), 30 deletions(-) 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] From 98de19540c20bf82a26d5a645c1093d1c4e73bb1 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 27 May 2026 14:28:19 -0400 Subject: [PATCH 4/8] fix(jetbrains): refine session transcript styling --- .changeset/jetbrains-session-hover.md | 2 +- .../client/session/views/MessageView.kt | 52 ++++++++++++++----- .../kilocode/client/session/views/TextView.kt | 5 +- .../kilocode/client/session/views/ToolView.kt | 35 +++++++++---- .../client/session/views/ViewFactory.kt | 5 ++ .../client/session/views/ReadToolViewTest.kt | 3 ++ .../client/session/views/ToolViewTest.kt | 15 ++++++ .../client/session/views/TurnViewTest.kt | 41 +++++++++++++++ 8 files changed, 133 insertions(+), 25 deletions(-) diff --git a/.changeset/jetbrains-session-hover.md b/.changeset/jetbrains-session-hover.md index 0a1b8d81b27..181ad76ac4a 100644 --- a/.changeset/jetbrains-session-hover.md +++ b/.changeset/jetbrains-session-hover.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Refine JetBrains session card hover styling and link completed read file rows to files in the IDE. +Refine JetBrains session transcript styling with subtler tool rows, prompt-styled user messages, and underlined read file links that open files in the IDE. 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 886437a11ee..52081071d76 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 @@ -11,8 +11,10 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle -import com.intellij.ui.RoundedLineBorder import com.intellij.util.ui.JBUI +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints /** * A single message container inside a [TurnView]. @@ -45,6 +47,7 @@ class MessageView( init { isOpaque = false + if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = style.editorScheme.defaultBackground border = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { userBorder() } else { @@ -55,7 +58,7 @@ class MessageView( for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = ViewFactory.create(content, openFile) + val view = view(content) view.applyStyle(style) parts[content.id] = view add(view) @@ -95,7 +98,7 @@ class MessageView( refresh() return } - val view = ViewFactory.create(content, openFile) + val view = view(content) view.applyStyle(style) parts[content.id] = view add(view) @@ -107,7 +110,7 @@ class MessageView( val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount parts.remove(content.id) remove(existing) - val view = ViewFactory.create(content, openFile) + val view = view(content) view.applyStyle(style) parts[content.id] = view add(view, at) @@ -147,7 +150,7 @@ class MessageView( for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = ViewFactory.create(content, openFile) + val view = view(content) view.applyStyle(style) parts[content.id] = view add(view) @@ -161,6 +164,12 @@ class MessageView( border = assistantBorder() } + private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { + ViewFactory.createUser(content, openFile) + } else { + ViewFactory.create(content, openFile) + } + /** Append a streaming delta to the renderer for [contentId]. */ fun appendDelta(contentId: String, delta: String) { val part = parts[contentId] ?: return @@ -179,22 +188,41 @@ class MessageView( override fun applyStyle(style: SessionEditorStyle) { this.style = style + if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = style.editorScheme.defaultBackground for (view in parts.values) view.applyStyle(style) refresh() } + override fun paintComponent(g: Graphics) { + if (msg.info.role != SessionUiStyle.View.Message.USER_ROLE) { + super.paintComponent(g) + return + } + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) + g2.color = style.editorScheme.defaultBackground + g2.fillRoundRect(0, 0, width, height, arc, arc) + g2.color = SessionUiStyle.View.line() + val w = width - 1 + val h = height - 1 + if (w > 0 && h > 0) g2.drawRoundRect(0, 0, w, h, arc, arc) + } finally { + g2.dispose() + } + super.paintComponent(g) + } + private fun refresh() { revalidate() repaint() } - private fun userBorder() = JBUI.Borders.compound( - RoundedLineBorder(SessionUiStyle.View.line(), JBUI.scale(SessionUiStyle.View.Message.USER_BORDER_ARC)), - JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Message.USER_BORDER_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Message.USER_BORDER_HORIZONTAL_PADDING), - ), - )!! + private fun userBorder() = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + ) private fun assistantBorder() = JBUI.Borders.empty() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index 86ab85cdbec..af19d386ef6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -12,7 +12,7 @@ import java.awt.BorderLayout * * Supports both full-replacement ([update]) and streaming append ([appendDelta]). */ -class TextView(text: Text) : PartView() { +class TextView(text: Text, transparent: Boolean = false) : PartView() { override val contentId: String = text.id @@ -21,6 +21,7 @@ class TextView(text: Text) : PartView() { init { layout = BorderLayout() isOpaque = false + md.opaque = !transparent applyStyle(SessionEditorStyle.current()) add(md.component, BorderLayout.CENTER) if (text.content.isNotEmpty()) md.set(text.content.toString()) @@ -41,6 +42,8 @@ class TextView(text: Text) : PartView() { /** Current markdown source — used by tests to assert rendered content. */ fun markdown(): String = md.markdown() + internal fun contentOpaque() = md.opaque + override fun applyStyle(style: SessionEditorStyle) { val changed = md.font != style.transcriptFont || md.codeFont != style.editorFamily if (md.font != style.transcriptFont) md.font = style.transcriptFont 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 0c16a68112c..e3dac956d48 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 @@ -9,11 +9,9 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.ToolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -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 @@ -22,8 +20,11 @@ import com.intellij.xml.util.XmlStringUtil import java.awt.BorderLayout import java.awt.CardLayout import java.awt.Color +import java.awt.Cursor import java.awt.Dimension import java.awt.Font +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import javax.swing.Box import javax.swing.Icon import javax.swing.JComponent @@ -32,7 +33,7 @@ import javax.swing.ScrollPaneConstants /** Renders non-read tool calls with VS Code-inspired rows/cards. */ class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) : - PrimarySessionPartView(parts.header, parts.scroll) { + SecondarySessionPartView(parts.header, parts.scroll) { override val contentId: String = tool.id @@ -189,7 +190,9 @@ class ReadToolView( 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 linkText() = parts.label + internal fun linkMarkup() = parts.link.text ?: "" + internal fun linkForeground() = parts.link.foreground internal fun linkHref() = parts.href internal fun openLink() = parts.openLink() @@ -227,7 +230,7 @@ class ReadToolView( parts.href = target.path changed = true } - changed = setText(parts.link, tail(target.path).ifBlank { target.path }) || changed + changed = setLinkText(parts, tail(target.path).ifBlank { target.path }) || changed changed = show(parts, true) || changed return changed } @@ -265,7 +268,7 @@ class ToolParts( val glyph: JBLabel, val title: JBLabel, val sub: JBLabel, - val link: ActionLink, + val link: JBLabel, val slot: JPanel, val state: JBLabel, val center: JPanel, @@ -275,6 +278,7 @@ class ToolParts( private val open: ((String) -> Unit)? = null, ) { var href: String? = null + var label: String = "" fun openLink() { val value = href ?: return @@ -290,10 +294,17 @@ private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolPar val glyph = JBLabel() val title = JBLabel() val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val link = ActionLink("") { parts.openLink() }.apply { + val link = JBLabel().apply { isVisible = false isFocusable = false + foreground = UiStyle.Colors.weak() + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) setRequestFocusEnabled(false) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + parts.openLink() + } + }) } val slot = JPanel(CardLayout()).apply { isOpaque = false @@ -368,9 +379,11 @@ 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 +private fun setLinkText(parts: ToolParts, text: String): Boolean { + val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(text)}") + if (parts.label == text && parts.link.text == value) return false + parts.label = text + parts.link.text = value return true } @@ -380,7 +393,7 @@ private fun show(parts: ToolParts, link: Boolean): Boolean { return true } -private fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.link.text ?: "" else parts.sub.text +private fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text private fun setIcon(label: JBLabel, icon: Icon): Boolean { if (label.icon === icon) return false 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 9c430651d74..62f72c5f095 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 @@ -36,6 +36,11 @@ object ViewFactory { is Generic -> GenericView(content) } + fun createUser(content: Content, openFile: (String) -> Unit): PartView = when (content) { + is Text -> TextView(content, transparent = true) + else -> create(content, openFile) + } + /** * Returns true when [view] must be replaced by a new renderer for [content]. * This happens when a running question tool (rendered as [ToolView]) completes 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 c31d4e65ab9..0a270461248 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.ui.UiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase import javax.swing.ScrollPaneConstants @@ -47,6 +48,8 @@ class ReadToolViewTest : BasePlatformTestCase() { assertTrue(view.linkVisible()) assertEquals("SessionUiLayoutTest.kt", view.linkText()) assertEquals(path, view.linkHref()) + assertTrue(view.linkMarkup().contains("SessionUiLayoutTest.kt")) + assertEquals(UiStyle.Colors.weak().rgb, view.linkForeground().rgb) assertTrue(view.labelText().contains("SessionUiLayoutTest.kt")) view.openLink() 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 6ace5750c4f..4f373abf1d5 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 @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView import com.intellij.testFramework.fixtures.BasePlatformTestCase import javax.swing.ScrollPaneConstants @@ -79,6 +80,20 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(view.bodyCreated()) } + fun `test bash tool uses secondary chrome`() { + val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + } + + fun `test unknown tool uses secondary chrome`() { + val view = ToolView(tool("p1", "mystery", ToolExecState.COMPLETED)) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + } + fun `test bash toggle collapses and expands`() { val t = tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "git log") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 729f876e1f0..8887e216639 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -96,6 +96,27 @@ class TurnViewTest : BasePlatformTestCase() { assertEquals("assistant", mv.role) } + fun `test user message uses prompt shell padding`() { + val mv = MessageView(msg("u1", "user"), openFile) + val ins = mv.border.getBorderInsets(mv) + + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), ins.top) + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), ins.bottom) + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), ins.left) + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), ins.right) + assertFalse(mv.isOpaque) + } + + fun `test assistant message remains borderless`() { + val mv = MessageView(msg("a1", "assistant"), openFile) + val ins = mv.border.getBorderInsets(mv) + + assertEquals(0, ins.top) + assertEquals(0, ins.bottom) + assertEquals(0, ins.left) + assertEquals(0, ins.right) + } + fun `test upsertPart adds a new TextView for Text content`() { val mv = MessageView(msg("a1", "assistant"), openFile) val text = ai.kilocode.client.session.model.Text("p1") @@ -106,6 +127,26 @@ class TurnViewTest : BasePlatformTestCase() { assertTrue(mv.part("p1") is TextView) } + fun `test user text view is transparent`() { + val mv = MessageView(msg("u1", "user"), openFile) + val text = ai.kilocode.client.session.model.Text("p1") + text.content.append("hello") + + mv.upsertPart(text) + + assertFalse((mv.part("p1") as TextView).contentOpaque()) + } + + fun `test assistant text view remains opaque`() { + val mv = MessageView(msg("a1", "assistant"), openFile) + val text = ai.kilocode.client.session.model.Text("p1") + text.content.append("hello") + + mv.upsertPart(text) + + assertTrue((mv.part("p1") as TextView).contentOpaque()) + } + fun `test upsertPart updates existing part rather than adding duplicate`() { val mv = MessageView(msg("a1", "assistant"), openFile) val t1 = ai.kilocode.client.session.model.Text("p1").also { it.content.append("v1") } From 5bf7bd8bbb126b5dcb8a74f3f93dd50084ee2657 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 27 May 2026 14:59:01 -0400 Subject: [PATCH 5/8] fix(jetbrains): open session markdown links --- .changeset/jetbrains-session-links.md | 5 +++++ .../ai/kilocode/client/session/SessionUi.kt | 7 ++++++- .../session/ui/SessionMessageListPanel.kt | 5 +++-- .../client/session/views/MessageView.kt | 5 +++-- .../client/session/views/ReasoningView.kt | 7 ++++++- .../kilocode/client/session/views/TextView.kt | 7 ++++++- .../kilocode/client/session/views/ToolView.kt | 12 ++++++++--- .../kilocode/client/session/views/TurnView.kt | 3 ++- .../client/session/views/ViewFactory.kt | 20 +++++++++++++------ .../session/ui/SessionMessageListPanelTest.kt | 12 +++++++++++ .../client/session/views/ReadToolViewTest.kt | 5 ++++- .../client/session/views/ReasoningViewTest.kt | 11 ++++++++++ .../client/session/views/TextViewTest.kt | 9 +++++++++ 13 files changed, 90 insertions(+), 18 deletions(-) create mode 100644 .changeset/jetbrains-session-links.md diff --git a/.changeset/jetbrains-session-links.md b/.changeset/jetbrains-session-links.md new file mode 100644 index 00000000000..63de324a815 --- /dev/null +++ b/.changeset/jetbrains-session-links.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support opening links in JetBrains session markdown transcripts. 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 e4aed46e47d..9c2a3d69285 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 @@ -36,6 +36,7 @@ import ai.kilocode.client.ui.layout.Stack import ai.kilocode.log.ChatLogSummary import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog +import com.intellij.ide.BrowserUtil import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -223,7 +224,7 @@ class SessionUi( reply = { id, dto -> controller.replyPermission(id, dto) }, ) login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() }) - messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile) + messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile, ::openUrl) header = SessionHeaderPanel(controller, this) scroll = SessionScroll(root, sessionContent, messageBody, blankBody) @@ -433,6 +434,10 @@ class SessionUi( } } + private fun openUrl(url: String) { + BrowserUtil.browse(url) + } + private fun onStateChanged(state: SessionState) { prompt.setBusy(state.isBusy()) load.setState(state) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index cad61f48fef..f21a2b669bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -47,6 +47,7 @@ class SessionMessageListPanel( private val permission: PermissionView? = null, private val login: LoginRequiredView? = null, private val openFile: (String) -> Unit, + private val openUrl: (String) -> Unit = {}, ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), JBUI.insets( @@ -174,7 +175,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style) + val tv = TurnView(turn.id, openFile, style, openUrl) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -225,7 +226,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style) + val tv = TurnView(turn.id, openFile, style, openUrl) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 52081071d76..efe87c02880 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 @@ -31,6 +31,7 @@ class MessageView( val msg: Message, private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), + private val openUrl: (String) -> Unit = {}, ) : ai.kilocode.client.session.ui.SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), ), SessionEditorStyleTarget, SessionView { @@ -165,9 +166,9 @@ class MessageView( } private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { - ViewFactory.createUser(content, openFile) + ViewFactory.createUser(content, openFile, openUrl) } else { - ViewFactory.create(content, openFile) + ViewFactory.create(content, openFile, openUrl) } /** Append a streaming delta to the renderer for [contentId]. */ 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 fe7ed35b31a..0ef0c7c2f6b 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 @@ -23,7 +23,11 @@ import javax.swing.ScrollPaneConstants import javax.swing.Scrollable /** Renders reasoning as a secondary collapsible block. */ -class ReasoningView(reasoning: Reasoning, private val parts: ReasoningParts = reasoningParts()) : +class ReasoningView( + reasoning: Reasoning, + openUrl: (String) -> Unit = {}, + private val parts: ReasoningParts = reasoningParts(), +) : SecondarySessionPartView(parts.header, parts.scroll) { override val contentId: String = reasoning.id @@ -37,6 +41,7 @@ class ReasoningView(reasoning: Reasoning, private val parts: ReasoningParts = re bindHeader(parts.title, parts.icon) applyStyle(style) md.opaque = false + md.addLinkListener { openUrl(it.href) } md.set(source) parts.panel.add(md.component, BorderLayout.CENTER) sync() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index af19d386ef6..ff00cdbe3b5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -12,7 +12,11 @@ import java.awt.BorderLayout * * Supports both full-replacement ([update]) and streaming append ([appendDelta]). */ -class TextView(text: Text, transparent: Boolean = false) : PartView() { +class TextView( + text: Text, + transparent: Boolean = false, + openUrl: (String) -> Unit = {}, +) : PartView() { override val contentId: String = text.id @@ -22,6 +26,7 @@ class TextView(text: Text, transparent: Boolean = false) : PartView() { layout = BorderLayout() isOpaque = false md.opaque = !transparent + md.addLinkListener { openUrl(it.href) } applyStyle(SessionEditorStyle.current()) add(md.component, BorderLayout.CENTER) if (text.content.isNotEmpty()) md.set(text.content.toString()) 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 e3dac956d48..b5ad3638aa3 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 @@ -189,10 +189,14 @@ class ReadToolView( internal fun hasToggle() = arrow.isVisible internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES + internal fun bodyFont() = parts.text.font internal fun linkVisible() = parts.link.isVisible internal fun linkText() = parts.label internal fun linkMarkup() = parts.link.text ?: "" internal fun linkForeground() = parts.link.foreground + internal fun linkFont() = parts.link.font + internal fun subtitleForeground() = parts.sub.foreground + internal fun subtitleFont() = parts.sub.font internal fun linkHref() = parts.href internal fun openLink() = parts.openLink() @@ -200,8 +204,8 @@ class ReadToolView( 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.sub, style.transcriptFont) || changed + changed = setFont(parts.link, style.transcriptFont) || changed changed = setFont(parts.state, style.smallEditorFont) || changed changed = setFont(parts.text, style.transcriptFont) || changed if (changed) refresh() @@ -216,6 +220,8 @@ class ReadToolView( changed = setText(parts.title, title(item)) || changed changed = syncSubtitle() || changed changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.fg()) || changed + changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed changed = setForeground(parts.text, bodyColor()) || changed @@ -297,7 +303,7 @@ private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolPar val link = JBLabel().apply { isVisible = false isFocusable = false - foreground = UiStyle.Colors.weak() + foreground = UiStyle.Colors.fg() cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) setRequestFocusEnabled(false) addMouseListener(object : MouseAdapter() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 2bea4ff421e..1b1d7283ead 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -20,6 +20,7 @@ class TurnView( val id: String, private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), + private val openUrl: (String) -> Unit = {}, ) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), SessionEditorStyleTarget { constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) @@ -32,7 +33,7 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, openFile, style) + val view = MessageView(msg, openFile, style, openUrl) messages[msg.info.id] = view add(view) revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 62f72c5f095..e3ce38fb78a 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 @@ -21,9 +21,13 @@ import ai.kilocode.client.session.views.todo.TodoWriteView * 3. Add a branch here — the exhaustive `when` will surface the gap as a compile error. */ object ViewFactory { - fun create(content: Content, openFile: (String) -> Unit): PartView = when (content) { - is Text -> TextView(content) - is Reasoning -> ReasoningView(content) + fun create( + content: Content, + openFile: (String) -> Unit, + openUrl: (String) -> Unit = {}, + ): PartView = when (content) { + is Text -> TextView(content, openUrl = openUrl) + is Reasoning -> ReasoningView(content, openUrl = openUrl) is Tool -> when { TodoWriteView.canRender(content) -> TodoWriteView(content) PlanExitView.canRender(content) -> PlanExitView(content, openFile) @@ -36,9 +40,13 @@ object ViewFactory { is Generic -> GenericView(content) } - fun createUser(content: Content, openFile: (String) -> Unit): PartView = when (content) { - is Text -> TextView(content, transparent = true) - else -> create(content, openFile) + fun createUser( + content: Content, + openFile: (String) -> Unit, + openUrl: (String) -> Unit = {}, + ): PartView = when (content) { + is Text -> TextView(content, transparent = true, openUrl = openUrl) + else -> create(content, openFile, openUrl) } /** 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 659d85d3cef..6d5a067309d 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 @@ -165,6 +165,18 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertTrue(mv.part("p1") is TextView) } + fun `test text markdown link uses panel url opener`() { + val urls = mutableListOf() + val item = SessionMessageListPanel(model, parent, openFile = openFile, openUrl = { urls.add(it) }) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "[docs](https://kilocode.ai/docs)")) + + val view = item.findMessage("a1")!!.part("p1") as TextView + view.md.simulateLink("https://kilocode.ai/docs") + + assertEquals(listOf("https://kilocode.ai/docs"), urls) + } + fun `test ContentDelta appends text to TextView`() { model.upsertMessage(msg("a1", "assistant")) model.updateContent("a1", part("p1", "a1", "text", text = "hello ")) 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 0a270461248..a1d27fab1bb 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 @@ -49,7 +49,8 @@ class ReadToolViewTest : BasePlatformTestCase() { assertEquals("SessionUiLayoutTest.kt", view.linkText()) assertEquals(path, view.linkHref()) assertTrue(view.linkMarkup().contains("SessionUiLayoutTest.kt")) - assertEquals(UiStyle.Colors.weak().rgb, view.linkForeground().rgb) + assertEquals(UiStyle.Colors.fg().rgb, view.linkForeground().rgb) + assertEquals(view.linkFont(), view.bodyFont()) assertTrue(view.labelText().contains("SessionUiLayoutTest.kt")) view.openLink() @@ -71,6 +72,8 @@ class ReadToolViewTest : BasePlatformTestCase() { assertFalse(view.linkVisible()) assertNull(view.linkHref()) + assertEquals(UiStyle.Colors.fg().rgb, view.subtitleForeground().rgb) + assertEquals(view.subtitleFont(), view.bodyFont()) assertTrue(view.labelText().contains(path)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt index 8a3692a1982..a343491ba80 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt @@ -163,6 +163,17 @@ class ReasoningViewTest : BasePlatformTestCase() { assertTrue(view.preferredSize.height > 0) } + fun `test link opens url callback`() { + val urls = mutableListOf() + val view = ReasoningView(reasoning("p1", done = true, text = "[docs](https://kilocode.ai/docs)"), openUrl = { + urls.add(it) + }) + + view.md.simulateLink("https://kilocode.ai/docs") + + assertEquals(listOf("https://kilocode.ai/docs"), urls) + } + private fun assertEditorSheet(sheet: String, style: SessionEditorStyle) { assertTrue(sheet.contains(style.editorFamily)) assertTrue(sheet.contains("${style.editorSize}pt")) 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 a457b45d9e2..e1684b69347 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 @@ -113,4 +113,13 @@ class TextViewTest : BasePlatformTestCase() { view.appendDelta("**") assertTrue(view.md.html().contains("")) } + + fun `test link opens url callback`() { + val urls = mutableListOf() + val view = TextView(Text("p1"), openUrl = { urls.add(it) }) + + view.md.simulateLink("https://kilocode.ai/docs") + + assertEquals(listOf("https://kilocode.ai/docs"), urls) + } } From 78141e031ac83a3235d38c4a76ebef829b01359c Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 27 May 2026 15:37:02 -0400 Subject: [PATCH 6/8] fix(jetbrains): invalidate generated api compilation --- packages/kilo-jetbrains/backend/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 224eb3e5a57..8139adcb753 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -84,10 +84,12 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) { tasks.named("compileKotlin") { dependsOn(fixGeneratedApi) + inputs.dir(generatedApi) } tasks.named("compileTestKotlin") { dependsOn(fixGeneratedApi) + inputs.dir(generatedApi) } val cliDir = layout.buildDirectory.dir("generated/cli/cli") From 2963dd712b16123bff396ef932bcc8da102b960f Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 27 May 2026 15:54:02 -0400 Subject: [PATCH 7/8] fix(jetbrains): fix test compilation after ViewFactory and SessionUiStyle changes --- .../ai/kilocode/client/session/views/PlanExitViewTest.kt | 4 ++-- .../kilocode/client/session/views/QuestionResultViewTest.kt | 6 +++--- .../src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt index 69e69bdf979..cc7a2811cf2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt @@ -19,7 +19,7 @@ class PlanExitViewTest : BasePlatformTestCase() { fun `test view factory replaces running tool with plan exit view when completed`() { val running = tool(ToolExecState.RUNNING) - val existing = ViewFactory.create(running) {} + val existing = ViewFactory.create(running, {}) {} assertTrue(existing is ToolView) val done = tool(ToolExecState.COMPLETED).apply { @@ -27,7 +27,7 @@ class PlanExitViewTest : BasePlatformTestCase() { } assertTrue(ViewFactory.shouldReplace(existing, done)) - assertTrue(ViewFactory.create(done) {} is PlanExitView) + assertTrue(ViewFactory.create(done, {}) {} is PlanExitView) } fun `test clicking plan link opens href`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index 34b6a4c4d3e..1d2c8a8e705 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -153,7 +153,7 @@ class QuestionResultViewTest : BasePlatformTestCase() { input = mapOf("questions" to """[{"question":"Q1"}]"""), metadata = mapOf("answers" to """[["A1"]]"""), ) - val view = ViewFactory.create(tool) {} + val view = ViewFactory.create(tool, {}) {} assertTrue(view is QuestionResultView) } @@ -163,14 +163,14 @@ class QuestionResultViewTest : BasePlatformTestCase() { input = emptyMap(), metadata = emptyMap(), ) - val view = ViewFactory.create(tool) {} + val view = ViewFactory.create(tool, {}) {} assertTrue(view is ToolView) } fun `test view factory falls back to tool view for running question`() { val tool = runningTool("question") - val view = ViewFactory.create(tool) {} + val view = ViewFactory.create(tool, {}) {} assertTrue(view is ToolView) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt index 73269d10499..085fca6a3d9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt @@ -29,7 +29,7 @@ class UiStyleTest : BasePlatformTestCase() { fun `test hover blends from panel toward border`() { val panel = Color(0, 0, 0) val border = UiStyle.Colors.contrast(panel, SessionUiStyle.View.BORDER_DELTA) - val hover = UiStyle.Colors.blend(panel, border, SessionUiStyle.View.HOVER_ALPHA) + val hover = UiStyle.Colors.blend(panel, border, SessionUiStyle.View.HOVER_FILL_ALPHA) assertTrue(hover.red > panel.red) assertTrue(hover.red < border.red) From 3cb6d77d8fd32e2e4af6001fc272b3fb1eb2b18a Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 27 May 2026 16:18:33 -0400 Subject: [PATCH 8/8] fix(jetbrains): address session view review feedback --- .../client/session/views/base/AbstractSessionPartView.kt | 2 +- .../ai/kilocode/client/session/views/base/GenericView.kt | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) 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 86dfe55498c..4141eef1f76 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 @@ -23,7 +23,7 @@ abstract class AbstractSessionPartView( protected val arrow = JBLabel() protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)) - private val bound = mutableListOf() + private val bound = linkedSetOf() private val click = object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt index 6b0c0343f30..c0973f9d166 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt @@ -13,12 +13,15 @@ import com.intellij.ui.components.JBLabel * confusing empty gaps), this shows a dim label with the raw type name. * This makes it easy to spot new part types that need a proper renderer. */ -class GenericView(content: Generic) : SecondarySessionPartView(JBLabel("[${content.type}]"), JBLabel()) { +class GenericView private constructor( + content: Generic, + private val label: JBLabel, +) : SecondarySessionPartView(label, JBLabel()) { + + constructor(content: Generic) : this(content, JBLabel("[${content.type}]")) override val contentId: String = content.id - private val label = row.getComponent(0) as JBLabel - init { label.foreground = UiStyle.Colors.weak() applyStyle(SessionEditorStyle.current())