fix(jetbrains): refine session transcript styling

This commit is contained in:
kirillk
2026-05-27 14:28:19 -04:00
parent 01cc964196
commit 98de19540c
8 changed files with 133 additions and 25 deletions
+1 -1
View File
@@ -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.
@@ -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()
}
@@ -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
@@ -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("<u>${XmlStringUtil.escapeString(text)}</u>")
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
@@ -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
@@ -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("<u>SessionUiLayoutTest.kt</u>"))
assertEquals(UiStyle.Colors.weak().rgb, view.linkForeground().rgb)
assertTrue(view.labelText().contains("SessionUiLayoutTest.kt"))
view.openLink()
@@ -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")
@@ -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") }