Merge branch 'main' into dog-hydrogen

This commit is contained in:
Kirill Kalishev
2026-05-27 17:00:32 -04:00
committed by GitHub
32 changed files with 1139 additions and 566 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Refine JetBrains session transcript styling with subtler tool rows, prompt-styled user messages, and underlined read file links that open files in the IDE.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support opening links in JetBrains session markdown transcripts.
@@ -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")
@@ -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
@@ -249,7 +250,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)
@@ -470,6 +471,10 @@ class SessionUi(
}
}
private fun openUrl(url: String) {
BrowserUtil.browse(url)
}
private fun onStateChanged(state: SessionState) {
prompt.setBusy(state.isBusy())
load.setState(state)
@@ -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<String, Message>()
@@ -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
@@ -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())
@@ -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].
@@ -29,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 {
@@ -45,6 +48,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 +59,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 +99,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 +111,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 +151,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 +165,12 @@ class MessageView(
border = assistantBorder()
}
private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) {
ViewFactory.createUser(content, openFile, openUrl)
} else {
ViewFactory.create(content, openFile, openUrl)
}
/** Append a streaming delta to the renderer for [contentId]. */
fun appendDelta(contentId: String, delta: String) {
val part = parts[contentId] ?: return
@@ -179,22 +189,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()
}
@@ -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,35 @@ 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,
openUrl: (String) -> Unit = {},
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.addLinkListener { openUrl(it.href) }
md.set(source)
body.add(md.component, BorderLayout.CENTER)
add(header, BorderLayout.NORTH)
parts.panel.add(md.component, BorderLayout.CENTER)
sync()
}
@@ -135,106 +69,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 +111,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 +120,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
@@ -12,7 +12,11 @@ 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,
openUrl: (String) -> Unit = {},
) : PartView() {
override val contentId: String = text.id
@@ -21,6 +25,8 @@ class TextView(text: Text) : PartView() {
init {
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())
@@ -41,6 +47,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
@@ -6,9 +6,10 @@ 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.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 com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLabel
@@ -17,8 +18,8 @@ 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.Component
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Font
@@ -29,109 +30,27 @@ 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)) :
SecondarySessionPartView(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.slot)
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 +59,302 @@ 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, subtitleText(parts), 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.link, 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,
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
}
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.slot)
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, 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 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()
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
var changed = false
changed = setFont(parts.title, style.boldEditorFont) || 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()
}
private fun sync(): Boolean {
var changed = false
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 = 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
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 = setLinkText(parts, 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
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 link: JBLabel,
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
var label: String = ""
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 = JBLabel().apply {
isVisible = false
isFocusable = false
foreground = UiStyle.Colors.fg()
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
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 {
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(slot, BorderLayout.CENTER)
add(glyph, BorderLayout.WEST)
add(center, BorderLayout.CENTER)
add(controls, BorderLayout.EAST)
}
parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, text, scroll, openFile)
return parts.also {
controls.add(it.state)
}
}
private fun icon(tool: Tool) = when (tool.name) {
"read" -> AllIcons.Actions.Preview
"bash" -> AllIcons.Debugger.Console
@@ -328,6 +385,22 @@ private fun setText(label: JBLabel, text: String): Boolean {
return true
}
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
}
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.label else parts.sub.text
private fun setIcon(label: JBLabel, icon: Icon): Boolean {
if (label.icon === icon) return false
label.icon = icon
@@ -375,10 +448,36 @@ private fun stateText(tool: Tool) = when (tool.state) {
}
private fun readPath(tool: Tool): String {
val target = target(tool)
if (target != null) {
if (target.type == "file") return tail(target.path).ifBlank { target.path }
return target.path
}
val path = tool.input["filePath"] ?: tool.input["path"] ?: tool.title ?: return tool.name
return tail(path).ifBlank { path }
}
private data class Target(
val path: String,
val type: String,
)
private fun target(tool: Tool): Target? {
val out = output(tool)
if (out.isBlank()) return null
val path = tag(out, "path") ?: return null
val type = tag(out, "type") ?: return null
return Target(path, type.lowercase())
}
private fun tag(text: String, name: String): String? =
Regex("<$name>\\s*([\\s\\S]*?)\\s*</$name>")
.find(text)
?.groupValues
?.getOrNull(1)
?.trim()
?.takeIf { it.isNotBlank() }
private fun shellTitle(tool: Tool): String =
tool.input["description"]?.takeIf { it.isNotBlank() }
?: tool.metadata["description"]?.takeIf { it.isNotBlank() }
@@ -453,9 +552,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()
}
@@ -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()
@@ -21,13 +21,18 @@ 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)
QuestionResultView.canRender(content) -> QuestionResultView(content)
ReadToolView.canRender(content) -> ReadToolView(content, openFile)
else -> ToolView(content)
}
is Compaction -> CompactionView(content)
@@ -35,6 +40,15 @@ object ViewFactory {
is Generic -> GenericView(content)
}
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)
}
/**
* Returns true when [view] must be replaced by a new renderer for [content].
* This happens when a running question tool (rendered as [ToolView]) completes
@@ -47,6 +61,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
}
@@ -0,0 +1,145 @@
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,
private val expandable: Boolean = true,
) : PartView() {
protected val arrow = JBLabel()
protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0))
private val bound = linkedSetOf<Component>()
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 && expandable) add(body, BorderLayout.CENTER)
if (!expandable) syncExpandable(false) else syncArrow()
}
fun isExpanded(): Boolean = body.parent === this
fun toggle() {
if (!expandable || !arrow.isVisible) return
val changed = if (isExpanded()) collapse() else expand()
if (!changed) return
syncArrow()
refresh()
}
fun expand(): Boolean {
if (!expandable) return false
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 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
}
protected fun bindHeader(vararg items: Component) {
items.forEach { bind(it) }
}
protected fun refresh() {
revalidate()
repaint()
}
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()
}
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
}
}
@@ -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,19 @@ 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 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 = JBLabel("[${content.type}]").apply {
foreground = UiStyle.Colors.weak()
border = com.intellij.util.ui.JBUI.Borders.empty(UiStyle.Gap.xs(), 0)
}
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
@@ -0,0 +1,32 @@
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(
header: JComponent,
content: JComponent,
expanded: Boolean = false,
expandable: Boolean = true,
) : AbstractSessionPartView(header, content, expanded, expandable) {
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()
override fun applyHover(value: Boolean, color: Color) {
border = if (value) SessionUiStyle.View.card(SessionUiStyle.View.hoverLine()) else SessionUiStyle.View.card()
repaint()
}
}
@@ -0,0 +1,23 @@
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,
expandable: Boolean = true,
) : AbstractSessionPartView(header, content, expanded, expandable) {
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()
}
@@ -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 {
@@ -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
@@ -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"))
@@ -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<String>()
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 "))
@@ -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`() {
@@ -127,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 ------
@@ -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`() {
@@ -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`() {
@@ -129,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)
}
@@ -139,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)
}
@@ -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)
}
}
@@ -0,0 +1,110 @@
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 ai.kilocode.client.ui.UiStyle
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 file output renders filename hyperlink`() {
val opened = mutableListOf<String>()
val path = "/Users/kirillk/work/kilocode/.kilo/worktrees/agreeable-marlin/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt"
val t = tool().also {
it.output = """
<path>$path</path>
<type>file</type>
<content>
content
</content>
""".trimIndent()
}
val view = ReadToolView(t, openFile = { opened.add(it) })
assertTrue(view.linkVisible())
assertEquals("SessionUiLayoutTest.kt", view.linkText())
assertEquals(path, view.linkHref())
assertTrue(view.linkMarkup().contains("<u>SessionUiLayoutTest.kt</u>"))
assertEquals(UiStyle.Colors.fg().rgb, view.linkForeground().rgb)
assertEquals(view.linkFont(), view.bodyFont())
assertTrue(view.labelText().contains("SessionUiLayoutTest.kt"))
view.openLink()
assertEquals(listOf(path), opened)
}
fun `test read directory output remains plain text`() {
val path = "/Users/kirillk/work/kilocode/packages/kilo-jetbrains"
val t = tool().also {
it.output = """
<path>$path</path>
<type>directory</type>
<content></content>
""".trimIndent()
}
val view = ReadToolView(t)
assertFalse(view.linkVisible())
assertNull(view.linkHref())
assertEquals(UiStyle.Colors.fg().rgb, view.subtitleForeground().rgb)
assertEquals(view.subtitleFont(), view.bodyFont())
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)
assertFalse(view.hasToggle())
assertFalse(view.isExpanded())
assertFalse(view.bodyVisible())
assertEquals("file contents", view.bodyText())
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy())
view.toggle()
assertFalse(view.isExpanded())
assertFalse(view.bodyVisible())
}
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 kind tools only`() {
assertTrue(ReadToolView.canRender(tool()))
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 }
}
@@ -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())
@@ -160,6 +163,17 @@ class ReasoningViewTest : BasePlatformTestCase() {
assertTrue(view.preferredSize.height > 0)
}
fun `test link opens url callback`() {
val urls = mutableListOf<String>()
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"))
@@ -113,4 +113,13 @@ class TextViewTest : BasePlatformTestCase() {
view.appendDelta("**")
assertTrue(view.md.html().contains("<strong>"))
}
fun `test link opens url callback`() {
val urls = mutableListOf<String>()
val view = TextView(Text("p1"), openUrl = { urls.add(it) })
view.md.simulateLink("https://kilocode.ai/docs")
assertEquals(listOf("https://kilocode.ai/docs"), urls)
}
}
@@ -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
@@ -57,15 +58,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")
@@ -88,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")
@@ -197,16 +203,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))
@@ -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") }
@@ -0,0 +1,128 @@
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() {
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)
}
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())
}
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, 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]
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)
}
}
@@ -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"))
@@ -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)