diff --git a/.changeset/jetbrains-session-hover.md b/.changeset/jetbrains-session-hover.md new file mode 100644 index 00000000000..181ad76ac4a --- /dev/null +++ b/.changeset/jetbrains-session-hover.md @@ -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. diff --git a/.changeset/jetbrains-session-links.md b/.changeset/jetbrains-session-links.md new file mode 100644 index 00000000000..63de324a815 --- /dev/null +++ b/.changeset/jetbrains-session-links.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support opening links in JetBrains session markdown transcripts. diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 224eb3e5a57..8139adcb753 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -84,10 +84,12 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) { tasks.named("compileKotlin") { dependsOn(fixGeneratedApi) + inputs.dir(generatedApi) } tasks.named("compileTestKotlin") { dependsOn(fixGeneratedApi) + inputs.dir(generatedApi) } val cliDir = layout.buildDirectory.dir("generated/cli/cli") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 6c084bb0a63..19628c239a0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -36,6 +36,7 @@ import ai.kilocode.client.ui.layout.Stack import ai.kilocode.log.ChatLogSummary import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog +import com.intellij.ide.BrowserUtil import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 79bf8eba3db..5b356d0ff5e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -35,7 +35,7 @@ class SessionModel { companion object { /** Part types that are internal server markers and must never be stored or rendered. */ - val SILENT_PART_TYPES = setOf("step-start") + val SILENT_PART_TYPES = setOf("step-start", "patch") } private val entries = LinkedHashMap() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index cad61f48fef..f21a2b669bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -47,6 +47,7 @@ class SessionMessageListPanel( private val permission: PermissionView? = null, private val login: LoginRequiredView? = null, private val openFile: (String) -> Unit, + private val openUrl: (String) -> Unit = {}, ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), JBUI.insets( @@ -174,7 +175,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style) + val tv = TurnView(turn.id, openFile, style, openUrl) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -225,7 +226,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style) + val tv = TurnView(turn.id, openFile, style, openUrl) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 023756b3071..433ad686227 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -3,7 +3,6 @@ package ai.kilocode.client.session.ui.style import ai.kilocode.client.ui.UiStyle import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI -import com.intellij.util.ui.JBUI.Borders.customLine import com.intellij.util.ui.UIUtil import java.awt.Color import javax.swing.border.Border @@ -26,7 +25,8 @@ object SessionUiStyle { const val CARD_BODY_EXTRA_HEIGHT = 16 internal const val BORDER_DELTA = 64 - internal const val HOVER_ALPHA = 0.35f + internal const val HOVER_BORDER_ALPHA = 0.18f + internal const val HOVER_FILL_ALPHA = 0.10f /** Creates a visible separator against editor-derived transcript surfaces. */ fun line(): Color = JBColor.lazy { UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) } @@ -35,12 +35,17 @@ object SessionUiStyle { fun header(): Color = UiStyle.Colors.editorBackground() - /** Local hover color for collapsible transcript card headers. */ - fun headerHover(): Color = JBColor.lazy { UiStyle.Colors.blend(header(), line(), HOVER_ALPHA) } + /** Subtle hover fill, softer than the card outline. */ + fun headerHover(): Color = JBColor.lazy { UiStyle.Colors.blend(header(), hoverLine(), HOVER_FILL_ALPHA) } - fun card(): Border = cardBorder() + /** Subtle hover outline, stronger than the hover fill. */ + fun hoverLine(): Color = JBColor.lazy { + UiStyle.Colors.blend(line(), JBUI.CurrentTheme.ActionButton.hoverBackground(), HOVER_BORDER_ALPHA) + } - fun cardBorder(): Border = JBUI.Borders.customLine(line(), 1) + fun card(color: Color = line()): Border = cardBorder(color) + + fun cardBorder(color: Color = line()): Border = JBUI.Borders.customLine(color, 1) fun cardTop(): Border = JBUI.Borders.customLineTop(line()) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 886437a11ee..efe87c02880 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 7faf0e3ac9b..0ef0c7c2f6b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -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 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..ff00cdbe3b5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -12,7 +12,11 @@ import java.awt.BorderLayout * * Supports both full-replacement ([update]) and streaming append ([appendDelta]). */ -class TextView(text: Text) : 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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt index 2cb3a4f30b4..b5ad3638aa3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt @@ -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("${XmlStringUtil.escapeString(text)}") + 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*") + .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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 2bea4ff421e..1b1d7283ead 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -20,6 +20,7 @@ class TurnView( val id: String, private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), + private val openUrl: (String) -> Unit = {}, ) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), SessionEditorStyleTarget { constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) @@ -32,7 +33,7 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, openFile, style) + val view = MessageView(msg, openFile, style, openUrl) messages[msg.info.id] = view add(view) revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 5ee3c959cd7..e3ce38fb78a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -21,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 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt new file mode 100644 index 00000000000..4141eef1f76 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -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() + + 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 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt index 0dcfb05e209..c0973f9d166 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt new file mode 100644 index 00000000000..cd43bccd7f3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt @@ -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() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt new file mode 100644 index 00000000000..ac99946eedc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt @@ -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() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt index e33303ede14..93150bad7a4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt @@ -258,7 +258,9 @@ class QuestionResultView(tool: Tool) : PartView() { val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() if (header.background?.rgb == color.rgb) return header.background = color + root.border = if (value) SessionUiStyle.View.card(SessionUiStyle.View.hoverLine()) else SessionUiStyle.View.card() header.repaint() + root.repaint() } private fun inside(e: MouseEvent): Boolean { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt index e1a57c26248..94efdd1fc6a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -6,89 +6,32 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.BorderLayout -import java.awt.Color -import java.awt.Component -import java.awt.Cursor import java.awt.Font -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent import javax.swing.Box import javax.swing.JComponent import javax.swing.JPanel -import javax.swing.SwingUtilities -class TodoWriteView(tool: Tool) : PartView() { +class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : + PrimarySessionPartView(parts.header, parts.list, expanded = true) { override val contentId = tool.id private var item = tool private var style = SessionEditorStyle.current() - private val root = JPanel(BorderLayout()).apply { - isOpaque = true - background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.card() - } - private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { - isOpaque = true - background = SessionUiStyle.View.header() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING), - ) - } - private val glyph = JBLabel(AllIcons.Actions.Checked) - private val title = JBLabel(KiloBundle.message("session.part.todo.title")) - private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - private val arrow = JBLabel(AllIcons.General.ArrowDown) - private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { - isOpaque = false - } - private val controls: JComponent = Box.createHorizontalBox().apply { add(arrow) } - private val list = TodoListPanel() - - private val click = object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - toggle() - } - } - private val mouse = object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { - setHover(true) - } - - override fun mouseExited(e: MouseEvent) { - if (inside(e)) return - setHover(false) - } - } - init { - layout = BorderLayout() - isOpaque = false - center.add(title, BorderLayout.WEST) - center.add(sub, BorderLayout.CENTER) - header.add(glyph, BorderLayout.WEST) - header.add(center, BorderLayout.CENTER) - header.add(controls, BorderLayout.EAST) - root.add(header, BorderLayout.NORTH) - root.add(list, BorderLayout.CENTER) - list.border = JBUI.Borders.compound( + bindHeader(parts.glyph, parts.title, parts.sub, parts.center, parts.controls) + parts.list.border = JBUI.Borders.compound( SessionUiStyle.View.cardTop(), JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()), ) - listOf(header, glyph, title, sub, arrow, center, controls).forEach { - bind(it) - it.addMouseListener(click) - } applyStyle(style) - add(root, BorderLayout.CENTER) sync() } @@ -101,98 +44,72 @@ class TodoWriteView(tool: Tool) : PartView() { override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false - changed = setFont(title, style.boldEditorFont) || changed - changed = setFont(sub, style.transcriptFont) || changed - list.applyStyle(style) + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.transcriptFont) || changed + parts.list.applyStyle(style) if (changed) refresh() } - fun toggle() { - val changed = if (isExpanded()) detach() else attach() - if (!changed) return - syncArrow() - refresh() - } - - fun isExpanded() = list.parent === root - - fun labelText(): String = listOf(title.text, sub.text).filter { it.isNotBlank() }.joinToString(" ") - - internal fun rowCount() = list.rowCount() - - internal fun rowText(index: Int) = list.rowText(index) - - internal fun rowChecked(index: Int) = list.rowChecked(index) - - internal fun rowCheckboxOpaque(index: Int) = list.rowCheckboxOpaque(index) - - internal fun rowForeground(index: Int) = list.rowForeground(index) - - internal fun hiddenText() = list.hiddenText() - - internal fun titleFont() = title.font - - internal fun subtitleFont() = sub.font + fun labelText(): String = listOf(parts.title.text, parts.sub.text).filter { it.isNotBlank() }.joinToString(" ") + internal fun rowCount() = parts.list.rowCount() + internal fun rowText(index: Int) = parts.list.rowText(index) + internal fun rowChecked(index: Int) = parts.list.rowChecked(index) + internal fun rowCheckboxOpaque(index: Int) = parts.list.rowCheckboxOpaque(index) + internal fun rowForeground(index: Int) = parts.list.rowForeground(index) + internal fun hiddenText() = parts.list.hiddenText() + internal fun titleFont() = parts.title.font + internal fun subtitleFont() = parts.sub.font override fun dumpLabel() = "TodoWriteView#$contentId(${labelText()})" private fun sync() { - sub.text = subtitle(item) + parts.sub.text = subtitle(item) val view = item.todoView val compact = view?.mode == "compact" val rows = if (compact) view.todos else item.todos - list.update( + parts.list.update( rows, hiddenBefore = if (compact) view.hiddenBefore else 0, hiddenAfter = if (compact) view.hiddenAfter else 0, ) - syncArrow() + syncExpandable(true) refresh() } - private fun attach(): Boolean { - if (isExpanded()) return false - root.add(list, BorderLayout.CENTER) - return true - } - - private fun detach(): Boolean { - if (!isExpanded()) return false - root.remove(list) - return true - } - - private fun syncArrow() { - arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight - } - - private fun setHover(value: Boolean) { - val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() - if (same(header.background, color)) return - header.background = color - header.repaint() - } - - private fun inside(e: MouseEvent): Boolean { - val point = SwingUtilities.convertPoint(e.component, e.point, header) - return header.contains(point) - } - - private fun bind(component: Component) { - component.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - component.addMouseListener(mouse) - } - - private fun refresh() { - revalidate() - repaint() - } - companion object { fun canRender(tool: Tool) = tool.name == "todowrite" && tool.state == ToolExecState.COMPLETED } } +class TodoParts( + val header: JPanel, + val glyph: JBLabel, + val title: JBLabel, + val sub: JBLabel, + val center: JPanel, + val controls: JComponent, + val list: TodoListPanel, +) + +private fun todoParts(): TodoParts { + val glyph = JBLabel(AllIcons.Actions.Checked) + val title = JBLabel(KiloBundle.message("session.part.todo.title")) + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { + isOpaque = false + add(title, BorderLayout.WEST) + add(sub, BorderLayout.CENTER) + } + val controls = Box.createHorizontalBox() + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { + isOpaque = false + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + return TodoParts(header, glyph, title, sub, center, controls, TodoListPanel()) +} + private fun subtitle(tool: Tool): String { val total = tool.todos.size if (total == 0) return "" @@ -205,5 +122,3 @@ private fun setFont(component: JComponent, font: Font): Boolean { component.font = font return true } - -private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index fcdf7586617..a691ddea409 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -509,18 +509,29 @@ class SessionModelTest : UsefulTestCase() { assertEquals("snapshot", (entry.parts["p2"] as Generic).type) } - fun `test loadHistory drops step-start and preserves step-finish parts`() { + fun `test loadHistory drops silent parts and preserves step-finish parts`() { val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "visible") val stepStart = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "step-start") val stepFinish = PartDto(id = "p3", sessionID = "s1", messageID = "m1", type = "step-finish") + val patch = PartDto(id = "p4", sessionID = "s1", messageID = "m1", type = "patch") - model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, stepStart, stepFinish)))) + model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, stepStart, stepFinish, patch)))) val entry = model.message("m1")!! assertEquals(listOf("p1", "p3"), entry.parts.keys.toList()) assertTrue(entry.parts["p3"] is StepFinish) } + fun `test updateContent drops patch parts`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.updateContent("m1", PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "patch")) + + assertFalse(model.message("m1")!!.parts.containsKey("p1")) + assertTrue(events.isEmpty()) + } + fun `test upsertMessage adds new message and returns true`() { val added = model.upsertMessage(msg("m1", "user")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 659d85d3cef..6d5a067309d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -165,6 +165,18 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertTrue(mv.part("p1") is TextView) } + fun `test text markdown link uses panel url opener`() { + val urls = mutableListOf() + val item = SessionMessageListPanel(model, parent, openFile = openFile, openUrl = { urls.add(it) }) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "[docs](https://kilocode.ai/docs)")) + + val view = item.findMessage("a1")!!.part("p1") as TextView + view.md.simulateLink("https://kilocode.ai/docs") + + assertEquals(listOf("https://kilocode.ai/docs"), urls) + } + fun `test ContentDelta appends text to TextView`() { model.upsertMessage(msg("a1", "assistant")) model.updateContent("a1", part("p1", "a1", "text", text = "hello ")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index e030a761883..4d0177f5666 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -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 ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt index 69e69bdf979..cc7a2811cf2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt @@ -19,7 +19,7 @@ class PlanExitViewTest : BasePlatformTestCase() { fun `test view factory replaces running tool with plan exit view when completed`() { val running = tool(ToolExecState.RUNNING) - val existing = ViewFactory.create(running) {} + val existing = ViewFactory.create(running, {}) {} assertTrue(existing is ToolView) val done = tool(ToolExecState.COMPLETED).apply { @@ -27,7 +27,7 @@ class PlanExitViewTest : BasePlatformTestCase() { } assertTrue(ViewFactory.shouldReplace(existing, done)) - assertTrue(ViewFactory.create(done) {} is PlanExitView) + assertTrue(ViewFactory.create(done, {}) {} is PlanExitView) } fun `test clicking plan link opens href`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index 97182fe0fca..1d2c8a8e705 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -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) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt new file mode 100644 index 00000000000..a1d27fab1bb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -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() + val path = "/Users/kirillk/work/kilocode/.kilo/worktrees/agreeable-marlin/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt" + val t = tool().also { + it.output = """ + $path + file + + content + + """.trimIndent() + } + + val view = ReadToolView(t, openFile = { opened.add(it) }) + + assertTrue(view.linkVisible()) + assertEquals("SessionUiLayoutTest.kt", view.linkText()) + assertEquals(path, view.linkHref()) + assertTrue(view.linkMarkup().contains("SessionUiLayoutTest.kt")) + 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 + directory + + """.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 } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt index f0cf4d20169..a343491ba80 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt @@ -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() + val view = ReasoningView(reasoning("p1", done = true, text = "[docs](https://kilocode.ai/docs)"), openUrl = { + urls.add(it) + }) + + view.md.simulateLink("https://kilocode.ai/docs") + + assertEquals(listOf("https://kilocode.ai/docs"), urls) + } + private fun assertEditorSheet(sheet: String, style: SessionEditorStyle) { assertTrue(sheet.contains(style.editorFamily)) assertTrue(sheet.contains("${style.editorSize}pt")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt index a457b45d9e2..e1684b69347 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt @@ -113,4 +113,13 @@ class TextViewTest : BasePlatformTestCase() { view.appendDelta("**") assertTrue(view.md.html().contains("")) } + + fun `test link opens url callback`() { + val urls = mutableListOf() + val view = TextView(Text("p1"), openUrl = { urls.add(it) }) + + view.md.simulateLink("https://kilocode.ai/docs") + + assertEquals(listOf("https://kilocode.ai/docs"), urls) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt index 1486718c479..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 @@ -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)) 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") } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt new file mode 100644 index 00000000000..523087c7a22 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt index 636dbb9f36f..444eda3bfec 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TodoViewDto import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -26,8 +27,10 @@ class TodoWriteViewTest : BasePlatformTestCase() { TodoDto("Next", "pending", "medium"), ) }) + val base: Any = view assertTrue(view.labelText().contains("To-dos")) + assertTrue(base is PrimarySessionPartView) assertTrue(view.labelText().contains("1/2")) assertTrue(view.isExpanded()) assertEquals(2, view.rowCount()) @@ -79,13 +82,13 @@ class TodoWriteViewTest : BasePlatformTestCase() { val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { it.todos = listOf(TodoDto("Old", "pending", "medium")) }) - val root = view.components.single() + val comps = view.components.toList() view.update(tool("todowrite", ToolExecState.COMPLETED).also { it.todos = listOf(TodoDto("New", "completed", "high")) }) - assertSame(root, view.components.single()) + assertEquals(comps, view.components.toList()) assertTrue(view.labelText().contains("1/1")) assertTrue(view.rowChecked(0)) assertTrue(view.rowText(0).contains("New")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt index 73269d10499..085fca6a3d9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt @@ -29,7 +29,7 @@ class UiStyleTest : BasePlatformTestCase() { fun `test hover blends from panel toward border`() { val panel = Color(0, 0, 0) val border = UiStyle.Colors.contrast(panel, SessionUiStyle.View.BORDER_DELTA) - val hover = UiStyle.Colors.blend(panel, border, SessionUiStyle.View.HOVER_ALPHA) + val hover = UiStyle.Colors.blend(panel, border, SessionUiStyle.View.HOVER_FILL_ALPHA) assertTrue(hover.red > panel.red) assertTrue(hover.red < border.red)