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") }