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