fix(kilo-jetbrains): polish profile and picker UI

This commit is contained in:
kirillk
2026-06-02 18:05:17 -04:00
parent ebeb65ad77
commit 7e856a57c4
12 changed files with 101 additions and 44 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve JetBrains model settings dropdown direction, logged-out profile branding, and CLI menu labeling.
@@ -5,7 +5,6 @@ import ai.kilocode.client.ui.PickerButton
import com.intellij.icons.AllIcons
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.ListPopup
import com.intellij.openapi.ui.popup.PopupShowOptions
import com.intellij.openapi.ui.popup.PopupStep
import com.intellij.openapi.ui.popup.util.BaseListPopupStep
import com.intellij.util.ui.EmptyIcon
@@ -17,8 +16,8 @@ import javax.swing.Icon
/**
* Clickable label-style dropdown picker with a native filled background.
*
* Shows the selected item's display text with an up-arrow. On click,
* opens a list popup above the picker. Disabled (greyed out, not
* Shows the selected item's display text with a down-arrow. On click,
* opens a list popup below the picker. Disabled (greyed out, not
* clickable) when no items are loaded.
*/
class ReasoningPicker : PickerButton() {
@@ -76,7 +75,7 @@ class ReasoningPicker : PickerButton() {
}
isVisible = true
val display = selected?.display ?: items.firstOrNull()?.display ?: ""
text = "$display "
text = "$display "
isEnabled = true
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
@@ -96,7 +95,7 @@ class ReasoningPicker : PickerButton() {
}
val popup: ListPopup = JBPopupFactory.getInstance().createListPopup(step)
popup.show(PopupShowOptions.aboveComponent(this))
popup.showUnderneathOf(this)
}
private fun icon(item: Item): Icon = if (item.id == selected?.id) checked else empty
@@ -5,7 +5,6 @@ import ai.kilocode.client.ui.PickerButton
import ai.kilocode.rpc.dto.ModelSelectionDto
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.PopupShowOptions
import com.intellij.openapi.ui.popup.util.PopupUtil
import com.intellij.ui.CollectionListModel
import com.intellij.ui.DocumentAdapter
@@ -115,7 +114,7 @@ class ModelPicker : PickerButton() {
return
}
val display = selected?.display
text = if (display == null && allowEmpty) "$emptyText " else "${ModelText.sanitize(display ?: items.first().display)} "
text = if (display == null && allowEmpty) "$emptyText " else "${ModelText.sanitize(display ?: items.first().display)} "
isEnabled = true
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
@@ -297,7 +296,7 @@ class ModelPicker : PickerButton() {
.setMovable(false)
.createPopup()
popup.show(PopupShowOptions.aboveComponent(this))
popup.showUnderneathOf(this)
SwingUtilities.invokeLater {
search.textEditor.requestFocusInWindow()
search.selectText()
@@ -3,17 +3,20 @@ package ai.kilocode.client.settings.profile
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.RoundedContentPanel
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.ProfileDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.RelativeFont
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import java.awt.KeyboardFocusManager
import java.awt.event.FocusEvent
import java.awt.event.FocusListener
@@ -22,6 +25,7 @@ import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.SwingConstants
import java.awt.BorderLayout
/**
* Retained logged-in UI. Labels, combo box, and buttons are built once and
@@ -43,6 +47,10 @@ internal class LoggedInProfileUi(
foreground = UiStyle.Colors.weak()
setCopyable(true)
}
private val logoLabel = JBLabel(IconLoader.getIcon("/icons/kilo-profile.svg", LoggedInProfileUi::class.java)).apply {
name = "kilo.profile.logo.loggedIn"
accessibleContext.accessibleName = KiloBundle.message("settings.kilo.displayName")
}
private val titleLabel = JBLabel(KiloBundle.message("profile.balance.title")).apply {
foreground = UiStyle.Colors.weak()
@@ -65,16 +73,10 @@ internal class LoggedInProfileUi(
private val balanceCard = RoundedContentPanel(UiStyle.Gap.pad(), UiStyle.Gap.xl()).apply {
name = "kilo.profile.balanceCard"
addToTop(titleLabel)
addToCenter(JPanel(GridBagLayout()).apply {
isOpaque = false
add(valueLabel, GridBagConstraints().apply {
gridx = 0; gridy = 0; anchor = GridBagConstraints.CENTER
})
add(refreshBtn, GridBagConstraints().apply {
gridx = 0; gridy = 1; anchor = GridBagConstraints.CENTER
insets = JBUI.insetsTop(UiStyle.Gap.pad())
})
})
addToCenter(Stack.vertical(UiStyle.Gap.pad())
.next(valueLabel)
.next(refreshBtn)
.align(HAlign.CENTER, VAlign.CENTER))
}
private val comboModel = DefaultComboBoxModel<String>()
@@ -85,29 +87,23 @@ internal class LoggedInProfileUi(
val logoutBtn = JButton(KiloBundle.message("profile.action.logout"))
.also { it.addActionListener { logout() } }
private val actionRow = JPanel(GridBagLayout()).apply {
add(dashboardBtn, GridBagConstraints().apply {
gridx = 0; gridy = 0; anchor = GridBagConstraints.WEST
})
add(logoutBtn, GridBagConstraints().apply {
gridx = 1; gridy = 0; anchor = GridBagConstraints.WEST
insets = JBUI.insetsLeft(UiStyle.Gap.md())
})
private val actionRow = Stack.horizontal(UiStyle.Gap.md())
.next(dashboardBtn)
.next(logoutBtn)
private val header = JPanel(BorderLayout()).apply {
isOpaque = false
add(Stack.vertical(UiStyle.Gap.lg())
.next(nameLabel)
.next(emailLabel), BorderLayout.CENTER)
add(logoLabel, BorderLayout.EAST)
}
private val rows: List<java.awt.Component> = listOf(nameLabel, emailLabel, combo, balanceCard, actionRow)
private val content = JPanel(GridBagLayout()).apply {
val gap = UiStyle.Gap.lg()
rows.forEachIndexed { i, comp ->
add(comp, GridBagConstraints().apply {
gridx = 0; gridy = i
weightx = 1.0
fill = GridBagConstraints.HORIZONTAL
anchor = GridBagConstraints.WEST
insets = if (i == 0) JBUI.emptyInsets() else JBUI.insetsTop(gap)
})
}
private val content = Stack.vertical(UiStyle.Gap.lg()).apply {
next(header)
next(combo)
next(balanceCard)
next(actionRow.align(HAlign.CENTER, VAlign.CENTER))
}
private var applying = false
@@ -7,6 +7,7 @@ import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.openapi.util.IconLoader
import com.intellij.openapi.ui.popup.Balloon
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.ui.SimpleColoredComponent
@@ -124,6 +125,12 @@ internal class LoggedOutProfileUi(
foreground = UiStyle.Colors.weak()
}
private val logoLabel = JBLabel(IconLoader.getIcon("/icons/kilo-profile.svg", LoggedOutProfileUi::class.java)).apply {
name = "kilo.profile.logo.loggedOut"
horizontalAlignment = SwingConstants.CENTER
accessibleContext.accessibleName = KiloBundle.message("settings.kilo.displayName")
}
private val errLabel = JBLabel().apply {
foreground = UiStyle.Colors.errorLabelForeground()
horizontalAlignment = SwingConstants.CENTER
@@ -179,11 +186,12 @@ internal class LoggedOutProfileUi(
private fun emptyCard(): JPanel {
val p = padded()
p.add(logoLabel, gbc(0).centered())
p.add(JBLabel(KiloBundle.message("profile.notLoggedIn")).apply {
foreground = UiStyle.Colors.weak()
horizontalAlignment = SwingConstants.CENTER
}, gbc(0))
p.add(loginBtn, gbc(1, UiStyle.Gap.sm()).centered())
}, gbc(1, UiStyle.Gap.pad()))
p.add(loginBtn, gbc(2, UiStyle.Gap.sm()).centered())
return p
}
@@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M512 0H0V512H512V0Z" fill="black"/>
<path d="M322 377H377V421H307.857L278 391.143V322H322V377ZM421 307.857L391.143 278H322V322L377 322V377H421V307.857ZM234 278H190V322H234V278ZM91 391.143L120.857 421H234V377H135V278H91V391.143ZM371.172 189.999V120.856L341.315 90.9995H278V135H327.172V189.999H278V233.999H421V189.999H371.172ZM135 91H91V233.999H135V184.5H190V233.999H234V184.5L190 140.5H135V91ZM234 91H190V140.5H234V91Z" fill="#FAF74F"/>
</svg>

After

Width:  |  Height:  |  Size: 558 B

@@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M512 0H0V512H512V0Z" fill="black"/>
<path d="M512 512H0V0H512V512ZM322.783 322.784H278.261V392.747L308.472 422.958H378.435V378.437H322.782L322.783 322.784ZM422.957 308.474L392.746 278.263H322.783V322.784H378.435L378.435 378.437H422.957L422.957 308.474ZM233.739 278.263H189.217V322.784H233.739V278.263ZM89.0435 392.747L119.254 422.958H233.739V378.437H133.565V278.263H89.043L89.0435 392.747ZM372.538 189.217V119.254L342.327 89.0435H278.261V133.565H328.017V189.217H278.261V233.739H422.957V189.217H372.538ZM133.565 89.0435H89.0435V233.739H133.565V183.652H189.218V233.739H233.74V183.652L189.218 139.13H133.565V89.0435ZM233.739 89.0435H189.217L189.218 139.13H233.739V89.0435Z" fill="#FAF74F"/>
</svg>

After

Width:  |  Height:  |  Size: 810 B

@@ -70,7 +70,7 @@
<action id="Kilo.OpenSettings"
class="ai.kilocode.client.actions.OpenSettingsAction"/>
<group id="Kilo.CliGroup" popup="true">
<group id="Kilo.CliGroup" text="CLI" popup="true">
<reference ref="Kilo.Restart"/>
<reference ref="Kilo.Reinstall"/>
</group>
@@ -26,6 +26,14 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() {
assertTrue("Reinstall should force-enable recovery action", event.presentation.isEnabled)
}
fun `test cli group has visible menu text`() {
val xml = requireNotNull(javaClass.classLoader.getResourceAsStream("kilo.jetbrains.frontend.xml"))
.bufferedReader()
.use { it.readText() }
assertTrue(xml.contains("<group id=\"Kilo.CliGroup\" text=\"CLI\" popup=\"true\">"))
}
private fun event(action: AnAction): AnActionEvent {
val presentation = Presentation().apply { copyFrom(action.templatePresentation) }
presentation.isEnabled = false
@@ -96,6 +96,7 @@ class PromptPanelTest : BasePlatformTestCase() {
assertTrue(panel.reasoning.isVisible)
assertEquals("high", panel.reasoning.selectedForTest()?.id)
assertEquals("High ▾", panel.reasoning.text)
}
fun `test reasoning picker aligns unchecked rows`() {
@@ -202,15 +202,18 @@ class ModelPickerTest : BasePlatformTestCase() {
picker.setItems(listOf(item("a", "A", "openai", "OpenAI")))
assertEquals("openai/a", picker.selectionKeyForTest())
assertEquals("A ▾", picker.text)
}
fun `test allowEmpty keeps empty selection`() {
val picker = ModelPicker()
picker.allowEmpty = true
picker.emptyText = "Not set"
picker.setItems(listOf(item("a", "A", "openai", "OpenAI")))
assertNull(picker.selectionKeyForTest())
assertEquals("Not set ▾", picker.text)
}
fun `test provider qualified default selects duplicate model id from correct provider`() {
@@ -161,6 +161,14 @@ class UserProfileConfigurableTest : BasePlatformTestCase() {
panel.setSize(800, 600)
layout(panel)
val logo = labelsByName(panel, "kilo.profile.logo.loggedIn").single()
val name = labels(panel).first { it.text == "Alice" }
val logoLoc = SwingUtilities.convertPoint(logo.parent, logo.location, panel)
val nameLoc = SwingUtilities.convertPoint(name.parent, name.location, panel)
assertNotNull(logo.icon)
assertTrue(logo.icon.iconWidth >= 64)
assertTrue(logoLoc.x > nameLoc.x)
val refresh = buttons(panel).first { it.text == "Refresh" }
assertFalse(refresh.isContentAreaFilled)
val card = refresh.parent
@@ -208,6 +216,28 @@ class UserProfileConfigurableTest : BasePlatformTestCase() {
}
}
fun `test logged out profile shows kilo icon above login content`() {
edt {
panel.update(null, KiloAppStatusDto.READY)
panel.setSize(800, 600)
layout(panel)
val logo = labelsByName(panel, "kilo.profile.logo.loggedOut").single()
val label = labels(panel).first { it.text == "Not logged in" }
val btn = buttons(panel).first { it.text == "Login with Kilo Code" }
val logoLoc = SwingUtilities.convertPoint(logo.parent, logo.location, panel)
val labelLoc = SwingUtilities.convertPoint(label.parent, label.location, panel)
val btnLoc = SwingUtilities.convertPoint(btn.parent, btn.location, panel)
assertTrue(visible(logo))
assertNotNull(logo.icon)
assertTrue(logo.icon.iconWidth >= 64)
assertTrue(logo.icon.iconHeight >= 64)
assertTrue(logoLoc.y < labelLoc.y)
assertTrue(labelLoc.y < btnLoc.y)
}
}
fun `test account update retains name label`() {
val alice = ProfileDto(email = "alice@test.com", name = "Alice")
val bob = ProfileDto(email = "bob@test.com", name = "Bob")