mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-30 19:32:39 +08:00
Merge branch 'main' into dog-hydrogen
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Support opening links in JetBrains session markdown transcripts.
|
||||
@@ -84,10 +84,12 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
|
||||
|
||||
tasks.named("compileKotlin") {
|
||||
dependsOn(fixGeneratedApi)
|
||||
inputs.dir(generatedApi)
|
||||
}
|
||||
|
||||
tasks.named("compileTestKotlin") {
|
||||
dependsOn(fixGeneratedApi)
|
||||
inputs.dir(generatedApi)
|
||||
}
|
||||
|
||||
val cliDir = layout.buildDirectory.dir("generated/cli/cli")
|
||||
|
||||
+6
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class SessionModel {
|
||||
|
||||
companion object {
|
||||
/** Part types that are internal server markers and must never be stored or rendered. */
|
||||
val SILENT_PART_TYPES = setOf("step-start")
|
||||
val SILENT_PART_TYPES = setOf("step-start", "patch")
|
||||
}
|
||||
|
||||
private val entries = LinkedHashMap<String, Message>()
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+11
-6
@@ -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())
|
||||
|
||||
|
||||
+41
-12
@@ -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()
|
||||
}
|
||||
|
||||
+59
-164
@@ -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
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
+299
-202
@@ -6,9 +6,10 @@ import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.Tool
|
||||
import ai.kilocode.client.session.model.ToolExecState
|
||||
import ai.kilocode.client.session.model.ToolKind
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.views.base.PartView
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.base.SecondarySessionPartView
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ui.components.JBLabel
|
||||
@@ -17,8 +18,8 @@ import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.xml.util.XmlStringUtil
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.CardLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Component
|
||||
import java.awt.Cursor
|
||||
import java.awt.Dimension
|
||||
import java.awt.Font
|
||||
@@ -29,109 +30,27 @@ import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
/** Renders tool calls with VS Code-inspired rows/cards. */
|
||||
class ToolView(tool: Tool) : PartView() {
|
||||
/** Renders non-read tool calls with VS Code-inspired rows/cards. */
|
||||
class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) :
|
||||
SecondarySessionPartView(parts.header, parts.scroll) {
|
||||
|
||||
override val contentId: String = tool.id
|
||||
|
||||
private var item = tool
|
||||
private var style = SessionEditorStyle.current()
|
||||
|
||||
private val root = JPanel(BorderLayout()).apply {
|
||||
isOpaque = true
|
||||
background = SessionUiStyle.View.surface()
|
||||
border = SessionUiStyle.View.card()
|
||||
}
|
||||
private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
|
||||
isOpaque = true
|
||||
background = SessionUiStyle.View.header()
|
||||
border = JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
|
||||
)
|
||||
}
|
||||
private val glyph = JBLabel()
|
||||
private val title = JBLabel()
|
||||
private val sub = JBLabel().apply {
|
||||
foreground = UiStyle.Colors.weak()
|
||||
}
|
||||
private val state = JBLabel().apply {
|
||||
foreground = UiStyle.Colors.weak()
|
||||
}
|
||||
private val arrow = JBLabel()
|
||||
private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
|
||||
isOpaque = false
|
||||
}
|
||||
private val controls: JComponent = Box.createHorizontalBox().apply {
|
||||
add(state)
|
||||
add(arrow)
|
||||
}
|
||||
private val text = JBTextArea().apply {
|
||||
isEditable = false
|
||||
caret.isVisible = false
|
||||
caret.isSelectionVisible = false
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
foreground = bodyColor()
|
||||
background = SessionUiStyle.View.surface()
|
||||
border = JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
|
||||
)
|
||||
}
|
||||
private val scroll = JBScrollPane(text).apply {
|
||||
border = SessionUiStyle.View.cardTop()
|
||||
isOpaque = true
|
||||
background = SessionUiStyle.View.surface()
|
||||
viewport.background = SessionUiStyle.View.surface()
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
}
|
||||
|
||||
private val click = object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (!canExpand(item)) return
|
||||
toggle()
|
||||
}
|
||||
}
|
||||
|
||||
private val mouse = object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) {
|
||||
setHover(true)
|
||||
}
|
||||
|
||||
override fun mouseExited(e: MouseEvent) {
|
||||
if (inside(e)) return
|
||||
setHover(false)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
layout = BorderLayout()
|
||||
isOpaque = false
|
||||
center.add(title, BorderLayout.WEST)
|
||||
center.add(sub, BorderLayout.CENTER)
|
||||
header.add(glyph, BorderLayout.WEST)
|
||||
header.add(center, BorderLayout.CENTER)
|
||||
header.add(controls, BorderLayout.EAST)
|
||||
root.add(header, BorderLayout.NORTH)
|
||||
|
||||
listOf(header, glyph, title, sub, state, arrow, center, controls).forEach {
|
||||
bind(it)
|
||||
it.addMouseListener(click)
|
||||
}
|
||||
text.text = preview(item)
|
||||
applyStyle(SessionEditorStyle.current())
|
||||
add(root, BorderLayout.CENTER)
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
|
||||
parts.text.text = preview(item)
|
||||
applyStyle(style)
|
||||
sync()
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val size = super.getPreferredSize()
|
||||
if (!bodyVisible()) return size
|
||||
val height = header.preferredSize.height + bodyMaxHeight()
|
||||
val height = row.preferredSize.height + bodyMaxHeight()
|
||||
return Dimension(size.width, minOf(size.height, height))
|
||||
}
|
||||
|
||||
@@ -140,164 +59,302 @@ class ToolView(tool: Tool) : PartView() {
|
||||
val was = item.name
|
||||
item = content
|
||||
var changed = false
|
||||
if (was != content.name || !canExpand(content)) changed = detach() || changed
|
||||
if (was != content.name || !canExpand(content)) changed = collapse() || changed
|
||||
changed = sync() || changed
|
||||
changed = syncBody() || changed
|
||||
if (changed) refresh()
|
||||
}
|
||||
|
||||
fun labelText(): String = listOf(title.text, sub.text, state.text).filter { it.isNotBlank() }.joinToString(" ")
|
||||
fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
|
||||
fun commandText(): String = command(item)
|
||||
|
||||
fun outputText(): String = output(item)
|
||||
|
||||
fun bodyText(): String = body(item)
|
||||
|
||||
internal fun previewText(): String = text.text
|
||||
|
||||
fun isExpanded(): Boolean = bodyVisible()
|
||||
|
||||
internal fun previewText(): String = parts.text.text
|
||||
fun hasToggle(): Boolean = arrow.isVisible
|
||||
|
||||
internal fun bodyFont() = text.font
|
||||
|
||||
internal fun titleFont() = title.font
|
||||
|
||||
internal fun subtitleFont() = sub.font
|
||||
|
||||
internal fun stateFont() = state.font
|
||||
|
||||
internal fun bodyEditable() = text.isEditable
|
||||
|
||||
internal fun bodyCaretVisible() = text.caret.isVisible
|
||||
|
||||
internal fun bodyVisible() = scroll.parent === root
|
||||
|
||||
internal fun bodyFont() = parts.text.font
|
||||
internal fun titleFont() = parts.title.font
|
||||
internal fun subtitleFont() = parts.sub.font
|
||||
internal fun stateFont() = parts.state.font
|
||||
internal fun bodyEditable() = parts.text.isEditable
|
||||
internal fun bodyCaretVisible() = parts.text.caret.isVisible
|
||||
internal fun bodyVisible() = parts.scroll.parent === this
|
||||
internal fun controlCount() = if (arrow.isVisible) 1 else 0
|
||||
|
||||
internal fun horizontalPolicy() = scroll.horizontalScrollBarPolicy
|
||||
|
||||
internal fun bodyWrap() = text.lineWrap
|
||||
|
||||
internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy
|
||||
internal fun bodyWrap() = parts.text.lineWrap
|
||||
internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES
|
||||
|
||||
internal fun bodyCreated() = true
|
||||
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
var changed = false
|
||||
changed = setFont(title, style.boldEditorFont) || changed
|
||||
changed = setFont(sub, style.smallEditorFont) || changed
|
||||
changed = setFont(state, style.smallEditorFont) || changed
|
||||
changed = setFont(text, style.transcriptFont) || changed
|
||||
changed = setFont(parts.title, style.boldEditorFont) || changed
|
||||
changed = setFont(parts.sub, style.smallEditorFont) || changed
|
||||
changed = setFont(parts.link, style.smallEditorFont) || changed
|
||||
changed = setFont(parts.state, style.smallEditorFont) || changed
|
||||
changed = setFont(parts.text, style.transcriptFont) || changed
|
||||
if (changed) refresh()
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
if (!canExpand(item)) return
|
||||
var changed = if (bodyVisible()) detach() else attach()
|
||||
changed = syncArrow() || changed
|
||||
if (changed) refresh()
|
||||
}
|
||||
|
||||
private fun setHover(value: Boolean) {
|
||||
val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
|
||||
if (same(header.background, color)) return
|
||||
header.background = color
|
||||
header.repaint()
|
||||
}
|
||||
|
||||
private fun inside(e: MouseEvent): Boolean {
|
||||
val point = SwingUtilities.convertPoint(e.component, e.point, header)
|
||||
return header.contains(point)
|
||||
}
|
||||
|
||||
private fun bind(component: Component) {
|
||||
component.addMouseListener(mouse)
|
||||
}
|
||||
|
||||
private fun sync(): Boolean {
|
||||
val expand = canExpand(item)
|
||||
val cursor = if (expand) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor()
|
||||
var changed = false
|
||||
changed = syncCursor(cursor) || changed
|
||||
changed = setVisible(arrow, expand) || changed
|
||||
changed = setVisible(state, !expand) || changed
|
||||
changed = syncArrow() || changed
|
||||
changed = syncExpandable(expand) || changed
|
||||
changed = setVisible(parts.state, !expand) || changed
|
||||
changed = syncLabels() || changed
|
||||
changed = setForeground(text, bodyColor()) || changed
|
||||
changed = setForeground(parts.text, bodyColor()) || changed
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun syncLabels(): Boolean {
|
||||
var changed = false
|
||||
changed = setIcon(glyph, icon(item)) || changed
|
||||
changed = setForeground(glyph, color(item)) || changed
|
||||
changed = setText(title, title(item)) || changed
|
||||
changed = setText(sub, subtitle(item)) || changed
|
||||
changed = setForeground(title, titleColor(item)) || changed
|
||||
changed = setText(state, stateText(item)) || changed
|
||||
changed = setForeground(state, color(item)) || changed
|
||||
changed = setIcon(parts.glyph, icon(item)) || changed
|
||||
changed = setForeground(parts.glyph, color(item)) || changed
|
||||
changed = setText(parts.title, title(item)) || changed
|
||||
changed = setText(parts.sub, subtitle(item)) || changed
|
||||
changed = setForeground(parts.title, titleColor(item)) || changed
|
||||
changed = setText(parts.state, stateText(item)) || changed
|
||||
changed = setForeground(parts.state, color(item)) || changed
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun syncArrow(): Boolean {
|
||||
val icon = if (bodyVisible()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
|
||||
return setIcon(arrow, icon)
|
||||
}
|
||||
|
||||
private fun syncBody(): Boolean {
|
||||
var changed = false
|
||||
val value = preview(item)
|
||||
if (text.text != value) {
|
||||
text.text = value
|
||||
text.caretPosition = 0
|
||||
if (parts.text.text != value) {
|
||||
parts.text.text = value
|
||||
parts.text.caretPosition = 0
|
||||
changed = true
|
||||
}
|
||||
changed = setForeground(text, bodyColor()) || changed
|
||||
changed = setForeground(parts.text, bodyColor()) || changed
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun attach(): Boolean {
|
||||
if (bodyVisible()) return false
|
||||
syncBody()
|
||||
root.add(scroll, BorderLayout.CENTER)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun detach(): Boolean {
|
||||
val attached = scroll.parent === root
|
||||
if (attached) root.remove(scroll)
|
||||
return attached
|
||||
}
|
||||
|
||||
private fun syncCursor(cursor: Cursor): Boolean {
|
||||
var changed = false
|
||||
listOf(header, glyph, title, sub, state, arrow, center, controls).forEach {
|
||||
if (it.cursor?.type != cursor.type) {
|
||||
it.cursor = cursor
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg()
|
||||
|
||||
private fun bodyMaxHeight(): Int {
|
||||
return text.getFontMetrics(text.font).height * bodyMaxRows() +
|
||||
return parts.text.getFontMetrics(parts.text.font).height * bodyMaxRows() +
|
||||
JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT)
|
||||
}
|
||||
|
||||
override fun dumpLabel() = "ToolView#$contentId(${labelText()})"
|
||||
}
|
||||
|
||||
/** Renders read calls with secondary, borderless chrome. */
|
||||
class ReadToolView(
|
||||
tool: Tool,
|
||||
openFile: (String) -> Unit = {},
|
||||
private val parts: ToolParts = toolParts(tool, openFile),
|
||||
) : SecondarySessionPartView(parts.header, parts.scroll, expandable = false) {
|
||||
|
||||
companion object {
|
||||
fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ
|
||||
}
|
||||
|
||||
override val contentId: String = tool.id
|
||||
|
||||
private var item = tool
|
||||
private var style = SessionEditorStyle.current()
|
||||
|
||||
init {
|
||||
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
|
||||
parts.text.text = preview(item)
|
||||
applyStyle(style)
|
||||
sync()
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val size = super.getPreferredSize()
|
||||
if (!bodyVisible()) return size
|
||||
val height = row.preferredSize.height + bodyMaxHeight()
|
||||
return Dimension(size.width, minOf(size.height, height))
|
||||
}
|
||||
|
||||
override fun update(content: Content) {
|
||||
if (content !is Tool) return
|
||||
item = content
|
||||
var changed = sync()
|
||||
changed = syncBody() || changed
|
||||
if (changed) refresh()
|
||||
}
|
||||
|
||||
fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
fun bodyText(): String = body(item)
|
||||
internal fun bodyVisible() = parts.scroll.parent === this
|
||||
internal fun hasToggle() = arrow.isVisible
|
||||
internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy
|
||||
internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES
|
||||
internal fun bodyFont() = parts.text.font
|
||||
internal fun linkVisible() = parts.link.isVisible
|
||||
internal fun linkText() = parts.label
|
||||
internal fun linkMarkup() = parts.link.text ?: ""
|
||||
internal fun linkForeground() = parts.link.foreground
|
||||
internal fun linkFont() = parts.link.font
|
||||
internal fun subtitleForeground() = parts.sub.foreground
|
||||
internal fun subtitleFont() = parts.sub.font
|
||||
internal fun linkHref() = parts.href
|
||||
internal fun openLink() = parts.openLink()
|
||||
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
var changed = false
|
||||
changed = setFont(parts.title, style.boldEditorFont) || changed
|
||||
changed = setFont(parts.sub, style.transcriptFont) || changed
|
||||
changed = setFont(parts.link, style.transcriptFont) || changed
|
||||
changed = setFont(parts.state, style.smallEditorFont) || changed
|
||||
changed = setFont(parts.text, style.transcriptFont) || changed
|
||||
if (changed) refresh()
|
||||
}
|
||||
|
||||
private fun sync(): Boolean {
|
||||
var changed = false
|
||||
changed = syncExpandable(false) || changed
|
||||
changed = setVisible(parts.state, true) || changed
|
||||
changed = setIcon(parts.glyph, icon(item)) || changed
|
||||
changed = setForeground(parts.glyph, color(item)) || changed
|
||||
changed = setText(parts.title, title(item)) || changed
|
||||
changed = syncSubtitle() || changed
|
||||
changed = setForeground(parts.title, titleColor(item)) || changed
|
||||
changed = setForeground(parts.sub, UiStyle.Colors.fg()) || changed
|
||||
changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed
|
||||
changed = setText(parts.state, stateText(item)) || changed
|
||||
changed = setForeground(parts.state, color(item)) || changed
|
||||
changed = setForeground(parts.text, bodyColor()) || changed
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun syncSubtitle(): Boolean {
|
||||
val target = target(item)?.takeIf { it.type == "file" }
|
||||
if (target != null) {
|
||||
var changed = false
|
||||
if (parts.href != target.path) {
|
||||
parts.href = target.path
|
||||
changed = true
|
||||
}
|
||||
changed = setLinkText(parts, tail(target.path).ifBlank { target.path }) || changed
|
||||
changed = show(parts, true) || changed
|
||||
return changed
|
||||
}
|
||||
|
||||
var changed = false
|
||||
if (parts.href != null) {
|
||||
parts.href = null
|
||||
changed = true
|
||||
}
|
||||
changed = setText(parts.sub, subtitle(item)) || changed
|
||||
changed = show(parts, false) || changed
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun syncBody(): Boolean {
|
||||
val value = preview(item)
|
||||
if (parts.text.text == value) return false
|
||||
parts.text.text = value
|
||||
parts.text.caretPosition = 0
|
||||
return true
|
||||
}
|
||||
|
||||
private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg()
|
||||
|
||||
private fun bodyMaxHeight(): Int {
|
||||
return parts.text.getFontMetrics(parts.text.font).height * bodyMaxRows() +
|
||||
JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT)
|
||||
}
|
||||
|
||||
override fun dumpLabel() = "ReadToolView#$contentId(${labelText()})"
|
||||
}
|
||||
|
||||
class ToolParts(
|
||||
val header: JPanel,
|
||||
val glyph: JBLabel,
|
||||
val title: JBLabel,
|
||||
val sub: JBLabel,
|
||||
val link: JBLabel,
|
||||
val slot: JPanel,
|
||||
val state: JBLabel,
|
||||
val center: JPanel,
|
||||
val controls: JComponent,
|
||||
val text: JBTextArea,
|
||||
val scroll: JBScrollPane,
|
||||
private val open: ((String) -> Unit)? = null,
|
||||
) {
|
||||
var href: String? = null
|
||||
var label: String = ""
|
||||
|
||||
fun openLink() {
|
||||
val value = href ?: return
|
||||
open?.invoke(value)
|
||||
}
|
||||
}
|
||||
|
||||
private const val SUB_CARD = "sub"
|
||||
private const val LINK_CARD = "link"
|
||||
|
||||
private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolParts {
|
||||
lateinit var parts: ToolParts
|
||||
val glyph = JBLabel()
|
||||
val title = JBLabel()
|
||||
val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() }
|
||||
val link = JBLabel().apply {
|
||||
isVisible = false
|
||||
isFocusable = false
|
||||
foreground = UiStyle.Colors.fg()
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
setRequestFocusEnabled(false)
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
parts.openLink()
|
||||
}
|
||||
})
|
||||
}
|
||||
val slot = JPanel(CardLayout()).apply {
|
||||
isOpaque = false
|
||||
add(sub, SUB_CARD)
|
||||
add(link, LINK_CARD)
|
||||
}
|
||||
val state = JBLabel().apply { foreground = UiStyle.Colors.weak() }
|
||||
val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { isOpaque = false }
|
||||
val text = JBTextArea().apply {
|
||||
isEditable = false
|
||||
caret.isVisible = false
|
||||
caret.isSelectionVisible = false
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg()
|
||||
background = SessionUiStyle.View.surface()
|
||||
border = JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
|
||||
)
|
||||
}
|
||||
val scroll = JBScrollPane(text).apply {
|
||||
border = SessionUiStyle.View.cardTop()
|
||||
isOpaque = true
|
||||
background = SessionUiStyle.View.surface()
|
||||
viewport.background = SessionUiStyle.View.surface()
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
}
|
||||
val controls = Box.createHorizontalBox()
|
||||
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
|
||||
isOpaque = false
|
||||
center.add(title, BorderLayout.WEST)
|
||||
center.add(slot, BorderLayout.CENTER)
|
||||
add(glyph, BorderLayout.WEST)
|
||||
add(center, BorderLayout.CENTER)
|
||||
add(controls, BorderLayout.EAST)
|
||||
}
|
||||
parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, text, scroll, openFile)
|
||||
return parts.also {
|
||||
controls.add(it.state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun icon(tool: Tool) = when (tool.name) {
|
||||
"read" -> AllIcons.Actions.Preview
|
||||
"bash" -> AllIcons.Debugger.Console
|
||||
@@ -328,6 +385,22 @@ private fun setText(label: JBLabel, text: String): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setLinkText(parts: ToolParts, text: String): Boolean {
|
||||
val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("<u>${XmlStringUtil.escapeString(text)}</u>")
|
||||
if (parts.label == text && parts.link.text == value) return false
|
||||
parts.label = text
|
||||
parts.link.text = value
|
||||
return true
|
||||
}
|
||||
|
||||
private fun show(parts: ToolParts, link: Boolean): Boolean {
|
||||
if (parts.link.isVisible == link && parts.sub.isVisible != link) return false
|
||||
(parts.slot.layout as CardLayout).show(parts.slot, if (link) LINK_CARD else SUB_CARD)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text
|
||||
|
||||
private fun setIcon(label: JBLabel, icon: Icon): Boolean {
|
||||
if (label.icon === icon) return false
|
||||
label.icon = icon
|
||||
@@ -375,10 +448,36 @@ private fun stateText(tool: Tool) = when (tool.state) {
|
||||
}
|
||||
|
||||
private fun readPath(tool: Tool): String {
|
||||
val target = target(tool)
|
||||
if (target != null) {
|
||||
if (target.type == "file") return tail(target.path).ifBlank { target.path }
|
||||
return target.path
|
||||
}
|
||||
val path = tool.input["filePath"] ?: tool.input["path"] ?: tool.title ?: return tool.name
|
||||
return tail(path).ifBlank { path }
|
||||
}
|
||||
|
||||
private data class Target(
|
||||
val path: String,
|
||||
val type: String,
|
||||
)
|
||||
|
||||
private fun target(tool: Tool): Target? {
|
||||
val out = output(tool)
|
||||
if (out.isBlank()) return null
|
||||
val path = tag(out, "path") ?: return null
|
||||
val type = tag(out, "type") ?: return null
|
||||
return Target(path, type.lowercase())
|
||||
}
|
||||
|
||||
private fun tag(text: String, name: String): String? =
|
||||
Regex("<$name>\\s*([\\s\\S]*?)\\s*</$name>")
|
||||
.find(text)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun shellTitle(tool: Tool): String =
|
||||
tool.input["description"]?.takeIf { it.isNotBlank() }
|
||||
?: tool.metadata["description"]?.takeIf { it.isNotBlank() }
|
||||
@@ -453,9 +552,7 @@ private fun plainBody(tool: Tool): String {
|
||||
}
|
||||
|
||||
private fun canExpand(tool: Tool): Boolean {
|
||||
if (tool.name == "bash") {
|
||||
return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank()
|
||||
}
|
||||
if (tool.name == "bash") return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank()
|
||||
return output(tool).isNotBlank() || !tool.error.isNullOrBlank()
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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()
|
||||
|
||||
+19
-3
@@ -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
|
||||
}
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package ai.kilocode.client.session.views.base
|
||||
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Component
|
||||
import java.awt.Cursor
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
abstract class AbstractSessionPartView(
|
||||
header: JComponent,
|
||||
protected val body: JComponent,
|
||||
expanded: Boolean = false,
|
||||
private val expandable: Boolean = true,
|
||||
) : PartView() {
|
||||
|
||||
protected val arrow = JBLabel()
|
||||
protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0))
|
||||
private val bound = linkedSetOf<Component>()
|
||||
|
||||
private val click = object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (!arrow.isVisible) return
|
||||
toggle()
|
||||
}
|
||||
}
|
||||
private val mouse = object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) {
|
||||
setHover(true)
|
||||
}
|
||||
|
||||
override fun mouseExited(e: MouseEvent) {
|
||||
if (inside(e)) return
|
||||
setHover(false)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
layout = BorderLayout()
|
||||
isOpaque = false
|
||||
row.add(header, BorderLayout.CENTER)
|
||||
row.add(arrow, BorderLayout.EAST)
|
||||
add(row, BorderLayout.NORTH)
|
||||
bindHeader(row, header, arrow)
|
||||
if (expanded && expandable) add(body, BorderLayout.CENTER)
|
||||
if (!expandable) syncExpandable(false) else syncArrow()
|
||||
}
|
||||
|
||||
fun isExpanded(): Boolean = body.parent === this
|
||||
|
||||
fun toggle() {
|
||||
if (!expandable || !arrow.isVisible) return
|
||||
val changed = if (isExpanded()) collapse() else expand()
|
||||
if (!changed) return
|
||||
syncArrow()
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun expand(): Boolean {
|
||||
if (!expandable) return false
|
||||
if (isExpanded()) return false
|
||||
add(body, BorderLayout.CENTER)
|
||||
return true
|
||||
}
|
||||
|
||||
fun collapse(): Boolean {
|
||||
if (!isExpanded()) return false
|
||||
remove(body)
|
||||
return true
|
||||
}
|
||||
|
||||
fun syncExpandable(expandable: Boolean): Boolean {
|
||||
val active = this.expandable && expandable
|
||||
val changed = setVisible(arrow, active)
|
||||
val detached = if (active) false else collapse()
|
||||
val cursor = if (active) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor()
|
||||
val moved = syncCursor(cursor)
|
||||
val icon = syncArrow()
|
||||
return changed || detached || moved || icon
|
||||
}
|
||||
|
||||
protected fun bindHeader(vararg items: Component) {
|
||||
items.forEach { bind(it) }
|
||||
}
|
||||
|
||||
protected fun refresh() {
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
protected open fun hoverColor(value: Boolean): Color? = null
|
||||
|
||||
protected open fun applyHover(value: Boolean, color: Color) {}
|
||||
|
||||
private fun setHover(value: Boolean) {
|
||||
val color = hoverColor(value) ?: return
|
||||
if (row.background?.rgb == color.rgb) return
|
||||
row.background = color
|
||||
applyHover(value, color)
|
||||
row.repaint()
|
||||
}
|
||||
|
||||
private fun inside(e: MouseEvent): Boolean {
|
||||
val point = SwingUtilities.convertPoint(e.component, e.point, row)
|
||||
return row.contains(point)
|
||||
}
|
||||
|
||||
private fun bind(component: Component) {
|
||||
if (bound.contains(component)) return
|
||||
bound.add(component)
|
||||
component.addMouseListener(click)
|
||||
component.addMouseListener(mouse)
|
||||
}
|
||||
|
||||
private fun syncCursor(cursor: Cursor): Boolean {
|
||||
var changed = false
|
||||
bound.forEach {
|
||||
if (it.cursor?.type != cursor.type) {
|
||||
it.cursor = cursor
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun syncArrow(): Boolean {
|
||||
val icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
|
||||
if (arrow.icon === icon) return false
|
||||
arrow.icon = icon
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setVisible(component: JComponent, visible: Boolean): Boolean {
|
||||
if (component.isVisible == visible) return false
|
||||
component.isVisible = visible
|
||||
return true
|
||||
}
|
||||
}
|
||||
+8
-10
@@ -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
|
||||
|
||||
+32
@@ -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()
|
||||
}
|
||||
}
|
||||
+23
@@ -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()
|
||||
}
|
||||
+2
@@ -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 {
|
||||
|
||||
+49
-134
@@ -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
|
||||
|
||||
+13
-2
@@ -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"))
|
||||
|
||||
|
||||
+12
@@ -165,6 +165,18 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
assertTrue(mv.part("p1") is TextView)
|
||||
}
|
||||
|
||||
fun `test text markdown link uses panel url opener`() {
|
||||
val urls = mutableListOf<String>()
|
||||
val item = SessionMessageListPanel(model, parent, openFile = openFile, openUrl = { urls.add(it) })
|
||||
model.upsertMessage(msg("a1", "assistant"))
|
||||
model.updateContent("a1", part("p1", "a1", "text", text = "[docs](https://kilocode.ai/docs)"))
|
||||
|
||||
val view = item.findMessage("a1")!!.part("p1") as TextView
|
||||
view.md.simulateLink("https://kilocode.ai/docs")
|
||||
|
||||
assertEquals(listOf("https://kilocode.ai/docs"), urls)
|
||||
}
|
||||
|
||||
fun `test ContentDelta appends text to TextView`() {
|
||||
model.upsertMessage(msg("a1", "assistant"))
|
||||
model.updateContent("a1", part("p1", "a1", "text", text = "hello "))
|
||||
|
||||
+9
@@ -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 ------
|
||||
|
||||
+2
-2
@@ -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`() {
|
||||
|
||||
+59
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.model.Tool
|
||||
import ai.kilocode.client.session.model.ToolExecState
|
||||
import ai.kilocode.client.session.model.toolKind
|
||||
import ai.kilocode.client.session.views.base.SecondarySessionPartView
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import javax.swing.ScrollPaneConstants
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class ReadToolViewTest : BasePlatformTestCase() {
|
||||
|
||||
fun `test read tool shows filename`() {
|
||||
val t = tool().also { it.input = mapOf("filePath" to "README.MD") }
|
||||
|
||||
val view = ReadToolView(t)
|
||||
val base: Any = view
|
||||
|
||||
assertTrue(base is SecondarySessionPartView)
|
||||
assertTrue(view.labelText().contains("Read"))
|
||||
assertTrue(view.labelText().contains("README.MD"))
|
||||
}
|
||||
|
||||
fun `test read tool handles windows path`() {
|
||||
val t = tool().also { it.input = mapOf("filePath" to "C:\\repo\\README.MD") }
|
||||
|
||||
val view = ReadToolView(t)
|
||||
|
||||
assertTrue(view.labelText().contains("README.MD"))
|
||||
}
|
||||
|
||||
fun `test read file output renders filename hyperlink`() {
|
||||
val opened = mutableListOf<String>()
|
||||
val path = "/Users/kirillk/work/kilocode/.kilo/worktrees/agreeable-marlin/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt"
|
||||
val t = tool().also {
|
||||
it.output = """
|
||||
<path>$path</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
content
|
||||
</content>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
val view = ReadToolView(t, openFile = { opened.add(it) })
|
||||
|
||||
assertTrue(view.linkVisible())
|
||||
assertEquals("SessionUiLayoutTest.kt", view.linkText())
|
||||
assertEquals(path, view.linkHref())
|
||||
assertTrue(view.linkMarkup().contains("<u>SessionUiLayoutTest.kt</u>"))
|
||||
assertEquals(UiStyle.Colors.fg().rgb, view.linkForeground().rgb)
|
||||
assertEquals(view.linkFont(), view.bodyFont())
|
||||
assertTrue(view.labelText().contains("SessionUiLayoutTest.kt"))
|
||||
|
||||
view.openLink()
|
||||
|
||||
assertEquals(listOf(path), opened)
|
||||
}
|
||||
|
||||
fun `test read directory output remains plain text`() {
|
||||
val path = "/Users/kirillk/work/kilocode/packages/kilo-jetbrains"
|
||||
val t = tool().also {
|
||||
it.output = """
|
||||
<path>$path</path>
|
||||
<type>directory</type>
|
||||
<content></content>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
val view = ReadToolView(t)
|
||||
|
||||
assertFalse(view.linkVisible())
|
||||
assertNull(view.linkHref())
|
||||
assertEquals(UiStyle.Colors.fg().rgb, view.subtitleForeground().rgb)
|
||||
assertEquals(view.subtitleFont(), view.bodyFont())
|
||||
assertTrue(view.labelText().contains(path))
|
||||
}
|
||||
|
||||
fun `test read output is secondary non expandable summary`() {
|
||||
val t = tool().also { it.output = "file contents" }
|
||||
val view = ReadToolView(t)
|
||||
|
||||
assertFalse(view.hasToggle())
|
||||
assertFalse(view.isExpanded())
|
||||
assertFalse(view.bodyVisible())
|
||||
assertEquals("file contents", view.bodyText())
|
||||
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy())
|
||||
|
||||
view.toggle()
|
||||
|
||||
assertFalse(view.isExpanded())
|
||||
assertFalse(view.bodyVisible())
|
||||
}
|
||||
|
||||
fun `test view factory routes read kind tools to read tool view`() {
|
||||
assertTrue(ViewFactory.create(tool(), openFile = {}) is ReadToolView)
|
||||
assertTrue(ViewFactory.create(Tool("p2", "grep", toolKind("grep")), openFile = {}) is ReadToolView)
|
||||
assertTrue(ViewFactory.create(Tool("p3", "glob", toolKind("glob")), openFile = {}) is ReadToolView)
|
||||
}
|
||||
|
||||
fun `test canRender matches read kind tools only`() {
|
||||
assertTrue(ReadToolView.canRender(tool()))
|
||||
assertTrue(ReadToolView.canRender(Tool("p2", "grep", toolKind("grep"))))
|
||||
assertTrue(ReadToolView.canRender(Tool("p3", "glob", toolKind("glob"))))
|
||||
assertFalse(ReadToolView.canRender(Tool("p4", "bash", toolKind("bash"))))
|
||||
}
|
||||
|
||||
private fun tool() = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED }
|
||||
}
|
||||
+14
@@ -2,6 +2,7 @@ package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.model.Reasoning
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.views.base.SecondarySessionPartView
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import javax.swing.ScrollPaneConstants
|
||||
|
||||
@@ -10,8 +11,10 @@ class ReasoningViewTest : BasePlatformTestCase() {
|
||||
|
||||
fun `test completed reasoning is collapsed by default`() {
|
||||
val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour"))
|
||||
val base: Any = view
|
||||
|
||||
assertFalse(view.isExpanded())
|
||||
assertTrue(base is SecondarySessionPartView)
|
||||
assertEquals("Reasoning", view.headerText())
|
||||
assertEquals("one\ntwo\nthree\nfour", view.markdown())
|
||||
assertTrue(view.hasToggle())
|
||||
@@ -160,6 +163,17 @@ class ReasoningViewTest : BasePlatformTestCase() {
|
||||
assertTrue(view.preferredSize.height > 0)
|
||||
}
|
||||
|
||||
fun `test link opens url callback`() {
|
||||
val urls = mutableListOf<String>()
|
||||
val view = ReasoningView(reasoning("p1", done = true, text = "[docs](https://kilocode.ai/docs)"), openUrl = {
|
||||
urls.add(it)
|
||||
})
|
||||
|
||||
view.md.simulateLink("https://kilocode.ai/docs")
|
||||
|
||||
assertEquals(listOf("https://kilocode.ai/docs"), urls)
|
||||
}
|
||||
|
||||
private fun assertEditorSheet(sheet: String, style: SessionEditorStyle) {
|
||||
assertTrue(sheet.contains(style.editorFamily))
|
||||
assertTrue(sheet.contains("${style.editorSize}pt"))
|
||||
|
||||
+9
@@ -113,4 +113,13 @@ class TextViewTest : BasePlatformTestCase() {
|
||||
view.appendDelta("**")
|
||||
assertTrue(view.md.html().contains("<strong>"))
|
||||
}
|
||||
|
||||
fun `test link opens url callback`() {
|
||||
val urls = mutableListOf<String>()
|
||||
val view = TextView(Text("p1"), openUrl = { urls.add(it) })
|
||||
|
||||
view.md.simulateLink("https://kilocode.ai/docs")
|
||||
|
||||
assertEquals(listOf("https://kilocode.ai/docs"), urls)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-19
@@ -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))
|
||||
|
||||
+41
@@ -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") }
|
||||
|
||||
+128
@@ -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)
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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"))
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user