refactor(jetbrains): consolidate model picker popup

This commit is contained in:
kirillk
2026-07-15 14:29:02 -04:00
parent 64017ac830
commit 14d343b24b
4 changed files with 602 additions and 445 deletions
@@ -2,6 +2,8 @@ package ai.kilocode.client.session.ui.model
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.PickerButton
import ai.kilocode.client.ui.picker.PickerPopup
import ai.kilocode.client.ui.picker.popupBackground
import ai.kilocode.rpc.dto.ModelAutoRoutingDto
import ai.kilocode.rpc.dto.ModelCapabilitiesDto
import ai.kilocode.rpc.dto.ModelCostDto
@@ -9,57 +11,19 @@ import ai.kilocode.rpc.dto.ModelLimitDto
import ai.kilocode.rpc.dto.ModelOptionsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.ModelTerminalBenchDto
import ai.kilocode.client.ui.HoverIcon
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.IconLoader
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
import com.intellij.ui.NewUI
import com.intellij.ui.ListUtil
import com.intellij.ui.SearchTextField
import com.intellij.ui.ScrollPaneFactory
import com.intellij.ui.ScrollingUtil
import com.intellij.ui.components.JBList
import com.intellij.ui.components.JBLabel
import com.intellij.ui.popup.AbstractPopup
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.xml.util.XmlStringUtil
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Cursor
import java.awt.Dimension
import java.awt.GridBagLayout
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JComponent
import javax.swing.JList
import javax.swing.JPanel
import javax.swing.JScrollPane
import javax.swing.KeyStroke
import javax.swing.ListSelectionModel
import javax.swing.ScrollPaneConstants
import javax.swing.SwingConstants
import javax.swing.SwingUtilities
import javax.swing.event.DocumentEvent
import javax.swing.Icon
private val popupBackground: Color
get() = if (NewUI.isEnabled()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground()
private const val MODEL_PICKER_MIN_WIDTH = 420
private const val MODEL_PICKER_MAX_WIDTH = 760
private const val MODEL_PICKER_MAX_VISIBLE_ROWS = 10
private const val MODEL_PICKER_EMPTY_LIST_HEIGHT = 120
private val EXPAND: Icon = IconLoader.getIcon("/icons/expand.svg", ModelPicker::class.java)
private val COLLAPSE: Icon = IconLoader.getIcon("/icons/collapse.svg", ModelPicker::class.java)
class ModelPicker : PickerButton() {
@@ -170,25 +134,13 @@ class ModelPicker : PickerButton() {
}
private fun showPopup() {
var expanded = props.getBoolean(MODEL_PICKER_EXPANDED_KEY, false)
val rows = modelPickerRows(items, favorites(), "", allowEmpty, emptyText, includeSmall)
val model = CollectionListModel(rows)
val list = JBList(model).apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
isFocusable = false
emptyText.text = KiloBundle.message("model.picker.no.matches")
background = popupBackground
border = JBUI.Borders.empty(PopupUtil.getListInsets(false, false))
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
list.cellRenderer = ModelPickerRenderer(
model = model,
val data = CollectionListModel(modelPickerRows(items, favorites(), "", allowEmpty, emptyText, includeSmall))
var popup: PickerPopup<ModelPickerRow>? = null
val renderer = ModelPickerRenderer(
model = data,
active = { selected?.key },
favorites = { favoriteKeys() },
)
val search = SearchTextField(false).apply {
textEditor.emptyText.text = KiloBundle.message("model.picker.search")
}
var refreshFavorite: (Item) -> Unit = {}
val details = ModelDetailsPanel(
favorites = { favoriteKeys() },
@@ -196,77 +148,17 @@ class ModelPicker : PickerButton() {
).apply {
background = popupBackground
}
val expand = HoverIcon().apply {
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
val ins = PopupUtil.getListInsets(false, false)
val side = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get() + JBUI.CurrentTheme.Popup.Selection.innerInsets().right
val expandWrap = JPanel(GridBagLayout()).apply {
background = popupBackground
border = JBUI.Borders.empty(0, JBUI.CurrentTheme.ActionsList.elementIconGap(), 0, side)
add(expand)
}
val head = JPanel(BorderLayout()).apply {
background = popupBackground
border = JBUI.Borders.empty(ins.top, ins.left, ins.bottom, 0)
add(search, BorderLayout.CENTER)
add(expandWrap, BorderLayout.EAST)
}
lateinit var popup: JBPopup
lateinit var content: JPanel
lateinit var scroll: JScrollPane
var shown = false
fun activeKey(): String? = list.selectedValue?.item?.key
fun preview(): Item? = list.selectedValue?.item ?: selected
fun syncDetails() {
details.update(preview())
}
fun syncExpand() {
expand.icon = if (expanded) COLLAPSE else EXPAND
expand.toolTipText = if (expanded) {
KiloBundle.message("model.picker.details.minimize")
} else {
KiloBundle.message("model.picker.details.maximize")
}
expand.accessibleContext.accessibleName = expand.toolTipText
details.isVisible = expanded
}
fun choose(idx: Int) {
list.selectedIndex = idx
ScrollingUtil.ensureIndexIsVisible(list, idx, 0)
}
fun sync(prefer: String? = activeKey(), at: Int? = null) {
val rows = modelPickerRows(items, favorites(), search.text, allowEmpty, emptyText, includeSmall)
model.replaceAll(rows)
val idx = at?.let { modelPickerIndex(rows, it) }?.takeIf { it >= 0 }
?: modelPickerIndex(rows, prefer).takeIf { it >= 0 }
?: modelPickerIndex(rows, selected?.key).takeIf { it >= 0 }
?: rows.indices.firstOrNull()
?: -1
if (idx >= 0) choose(idx)
else list.clearSelection()
syncDetails()
}
fun activate(item: Item) {
selected = item
refresh()
onSelect(item)
popup.closeOk(null)
}
fun clear() {
selected = null
refresh()
onClear()
popup.closeOk(null)
}
fun activate(row: ModelPickerRow) {
@@ -278,166 +170,42 @@ class ModelPicker : PickerButton() {
activate(item)
}
fun move(step: Int) {
val size = model.size
if (size <= 0) return
val cur = list.selectedIndex.takeIf { it >= 0 } ?: 0
val idx = (cur + step).coerceIn(0, size - 1)
choose(idx)
}
fun toggle(row: ModelPickerRow) {
val item = row.item ?: return
val idx = list.selectedIndex
onFavoriteToggle(item)
sync(at = idx)
list.selectedIndex.takeIf { it >= 0 }?.let { repaintRow(list, it) }
syncDetails()
}
refreshFavorite = { item ->
onFavoriteToggle(item)
sync(prefer = item.key)
list.repaint()
popup?.refresh(prefer = item.key)
popup?.repaint()
}
fun resize() {
val size = computeInitialPopupSize(list, scroll, head, expanded)
content.preferredSize = size
if (expanded) {
details.preferredSize = Dimension(size.width - scroll.preferredSize.width, scroll.preferredSize.height)
}
content.revalidate()
content.repaint()
if (shown) popup.setSize(size)
}
fun setExpanded(value: Boolean) {
if (expanded == value) return
expanded = value
props.setValue(MODEL_PICKER_EXPANDED_KEY, value.toString())
if (!expanded) list.clearSelection()
syncExpand()
syncDetails()
resize()
}
search.textEditor.document.addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
sync()
}
})
search.textEditor.registerKeyboardAction(
{ move(-1) },
KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0),
JComponent.WHEN_FOCUSED,
popup = PickerPopup(
anchor = this,
placement = when (placement) {
Placement.ABOVE -> PickerPopup.Placement.ABOVE
Placement.BELOW -> PickerPopup.Placement.BELOW
},
rows = { q -> modelPickerRows(items, favorites(), q, allowEmpty, emptyText, includeSmall) },
model = data,
renderer = renderer,
key = { it.key },
mode = PickerPopup.Mode.Single,
onPrimary = ::activate,
sectionTitle = ::modelPickerSectionTitle,
trailingHit = ModelPickerRenderer::isFavoriteClick,
onTrailing = ::toggle,
search = true,
details = details,
onPreview = { details.update(it?.item ?: selected) },
expandStateKey = MODEL_PICKER_EXPANDED_KEY,
minWidth = MODEL_PICKER_MIN_WIDTH,
maxWidth = MODEL_PICKER_MAX_WIDTH,
maxVisibleRows = MODEL_PICKER_MAX_VISIBLE_ROWS,
emptyListHeight = MODEL_PICKER_EMPTY_LIST_HEIGHT,
)
search.textEditor.registerKeyboardAction(
{ move(1) },
KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0),
JComponent.WHEN_FOCUSED,
)
search.textEditor.registerKeyboardAction(
{ list.selectedValue?.let(::activate) },
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
JComponent.WHEN_FOCUSED,
)
search.textEditor.registerKeyboardAction(
{ popup.cancel() },
KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
JComponent.WHEN_FOCUSED,
)
search.textEditor.registerKeyboardAction(
{ list.selectedValue?.let(::toggle) },
KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, InputEvent.SHIFT_DOWN_MASK),
JComponent.WHEN_FOCUSED,
)
list.registerKeyboardAction(
{ list.selectedValue?.let(::activate) },
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
JComponent.WHEN_FOCUSED,
)
list.registerKeyboardAction(
{ popup.cancel() },
KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
JComponent.WHEN_FOCUSED,
)
list.registerKeyboardAction(
{ list.selectedValue?.let(::toggle) },
KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, InputEvent.SHIFT_DOWN_MASK),
JComponent.WHEN_FOCUSED,
)
list.addMouseListener(object : MouseAdapter() {
override fun mouseReleased(e: MouseEvent) {
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
val row = list.locationToIndex(e.point)
val bounds = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return
if (!bounds.contains(e.point)) return
val value = model.getElementAt(row)
if (ModelPickerRenderer.isFavoriteClick(list, bounds, e.point)) {
toggle(value)
e.consume()
return
}
activate(value)
}
})
expand.addActionListener { setExpanded(!expanded) }
list.addListSelectionListener {
if (!it.valueIsAdjusting && expanded) syncDetails()
}
ListUtil.installAutoSelectOnMouseMove(list)
ScrollingUtil.installActions(list)
scroll = ScrollPaneFactory.createScrollPane(list).apply {
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
background = popupBackground
viewport.background = popupBackground
viewport.isOpaque = true
}
content = JPanel(BorderLayout()).apply {
background = popupBackground
border = JBUI.Borders.empty()
add(head, BorderLayout.NORTH)
add(scroll, BorderLayout.CENTER)
add(details, BorderLayout.EAST)
}
PopupUtil.applyNewUIBackground(list)
list.background = popupBackground
AbstractPopup.customizeSearchFieldLook(search, true)
search.background = popupBackground
sync(selected?.key)
syncExpand()
syncDetails()
content.preferredSize = computeInitialPopupSize(list, scroll, head, expanded)
if (expanded) details.preferredSize = Dimension(content.preferredSize.width - scroll.preferredSize.width, scroll.preferredSize.height)
popup = JBPopupFactory.getInstance()
.createComponentPopupBuilder(content, search.textEditor)
.setRequestFocus(true)
.setFocusable(true)
.setCancelOnClickOutside(true)
.setCancelKeyEnabled(true)
.setCancelOnWindowDeactivation(true)
.setLocateWithinScreenBounds(true)
.setResizable(false)
.setMovable(false)
.createPopup()
Disposer.register(popup, details)
when (placement) {
Placement.ABOVE -> popup.show(PopupShowOptions.aboveComponent(this))
Placement.BELOW -> popup.showUnderneathOf(this)
}
shown = true
SwingUtilities.invokeLater {
search.textEditor.requestFocusInWindow()
search.selectText()
list.selectedIndex.takeIf { it >= 0 }?.let(list::ensureIndexIsVisible)
}
popup.show()
}
private fun favoriteKeys(): Set<String> = favorites().mapTo(mutableSetOf()) { "${it.providerID}/${it.modelID}" }
@@ -457,56 +225,6 @@ internal data class ModelPickerRow(
val isEmpty: Boolean get() = item == null
}
private fun computeInitialPopupSize(
list: JList<ModelPickerRow>,
scroll: JScrollPane,
head: JComponent,
expanded: Boolean = false,
): Dimension {
val width = computeListPreferredWidth(list)
list.fixedCellWidth = width
val height = computeListPreferredHeight(list)
val bar = if (list.model.size > MODEL_PICKER_MAX_VISIBLE_ROWS) scroll.verticalScrollBar.preferredSize.width else 0
val listWidth = width + bar
val detailWidth = if (expanded) width else 0
val content = Dimension(listWidth + detailWidth, head.preferredSize.height + height)
scroll.preferredSize = Dimension(listWidth, height)
return content
}
private fun computeListPreferredWidth(list: JList<ModelPickerRow>): Int {
val renderer = list.cellRenderer ?: return JBUI.scale(MODEL_PICKER_MIN_WIDTH)
val model = list.model
val max = (0 until model.size).maxOfOrNull { idx ->
val value = model.getElementAt(idx)
renderer.getListCellRendererComponent(list, value, idx, false, false).preferredSize.width
} ?: 0
val insets = list.insets
return (max + insets.left + insets.right).coerceIn(
JBUI.scale(MODEL_PICKER_MIN_WIDTH),
JBUI.scale(MODEL_PICKER_MAX_WIDTH),
)
}
private fun computeListPreferredHeight(list: JList<ModelPickerRow>): Int {
val renderer = list.cellRenderer ?: return JBUI.scale(MODEL_PICKER_EMPTY_LIST_HEIGHT)
val model = list.model
val count = model.size.coerceAtMost(MODEL_PICKER_MAX_VISIBLE_ROWS)
if (count <= 0) return JBUI.scale(MODEL_PICKER_EMPTY_LIST_HEIGHT)
val height = (0 until count).sumOf { idx ->
val value = model.getElementAt(idx)
renderer.getListCellRendererComponent(list, value, idx, false, false).preferredSize.height
}
val insets = list.insets
return height + insets.top + insets.bottom
}
private fun repaintRow(list: JList<*>, index: Int) {
if (index < 0) return
list.getCellBounds(index, index)?.let(list::repaint)
}
internal object ModelSearch {
fun matches(query: String, text: String): Boolean {
val q = query.lowercase().trim()
@@ -1,119 +1,52 @@
package ai.kilocode.client.session.ui.model
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.picker.PickerListRenderer
import com.intellij.icons.AllIcons
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.CollectionListModel
import com.intellij.ui.GroupHeaderSeparator
import com.intellij.ui.NewUI
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.FlowLayout
import java.awt.Point
import java.awt.Rectangle
import javax.swing.Icon
import javax.swing.JList
import javax.swing.JPanel
import javax.swing.ListCellRenderer
import javax.swing.SwingConstants
private const val FAVORITE_CLICK_AREA_WIDTH = 32
internal class ModelPickerRenderer(
private val model: CollectionListModel<ModelPickerRow>,
private val active: () -> String?,
internal class ModelPickerRenderer private constructor(
model: CollectionListModel<ModelPickerRow>,
active: () -> String?,
private val favorites: () -> Set<String>,
) : JPanel(BorderLayout()), ListCellRenderer<ModelPickerRow> {
private val parts: Parts,
) : PickerListRenderer<ModelPickerRow>(
model = model,
checked = { it.key == active() },
sectionTitle = ::modelPickerSectionTitle,
content = parts.head,
trailing = parts.star,
) {
constructor(
model: CollectionListModel<ModelPickerRow>,
active: () -> String?,
favorites: () -> Set<String>,
) : this(model, active, favorites, Parts.create())
companion object {
val DATA_COLLECTED: Icon = IconLoader.getIcon("/icons/book-open-check.svg", ModelPickerRenderer::class.java)
val checked: Icon = AllIcons.Actions.Checked
val empty: Icon = EmptyIcon.create(checked)
val checked: Icon = PickerListRenderer.checkedIcon
val empty: Icon = PickerListRenderer.emptyIcon
fun isFavoriteClick(list: JList<*>, bounds: Rectangle, point: Point): Boolean {
val width = JBUI.scale(FAVORITE_CLICK_AREA_WIDTH)
val inset = favoriteInset(list)
if (list.componentOrientation.isLeftToRight) {
val right = bounds.x + bounds.width - inset
return point.x in (right - width)..right
}
val left = bounds.x + inset
return point.x in left..(left + width)
return PickerListRenderer.trailingClickZone(list, bounds, point, FAVORITE_CLICK_AREA_WIDTH)
}
private fun favoriteInset(list: JList<*>): Int {
if (!NewUI.isEnabled()) return 0
val inner = JBUI.CurrentTheme.Popup.Selection.innerInsets()
val edge = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get()
return edge + if (list.componentOrientation.isLeftToRight) inner.right else inner.left
}
}
private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets())
private val top = JPanel(BorderLayout()).apply {
border = JBUI.Borders.empty()
add(sep, BorderLayout.NORTH)
}
private val check = JBLabel().apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
}
private val title = SimpleColoredComponent()
private val badge = FilledBadgeIcon(
ModelText.freeLabel(),
UiStyle.Badge.Highlight,
)
private val badgeLabel = BadgeLabel(badge).apply {
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
private val byok = FilledBadgeIcon(
"BYOK",
UiStyle.Badge.Highlight,
)
private val byokLabel = BadgeLabel(byok).apply {
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
private val warn = JBLabel(DATA_COLLECTED).apply {
toolTipText = ModelText.dataCollected()
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
private val provider = JBLabel()
private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
add(title)
add(warn)
add(badgeLabel)
add(byokLabel)
add(provider)
}
private val star = JBLabel().apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
}
private val row = JPanel(BorderLayout()).apply {
add(check, BorderLayout.WEST)
add(head, BorderLayout.CENTER)
}
private val wrap = PickerRow()
init {
isOpaque = true
top.isOpaque = true
UiStyle.Components.transparent(row, check, title, head, warn, provider, star)
row.border = JBUI.Borders.empty(
UiStyle.Gap.md(),
UiStyle.Gap.lg(),
UiStyle.Gap.md(),
UiStyle.Gap.pad(),
)
wrap.setContent(row, star)
add(top, BorderLayout.NORTH)
add(wrap, BorderLayout.CENTER)
}
override fun getListCellRendererComponent(
@@ -123,70 +56,105 @@ internal class ModelPickerRenderer(
selected: Boolean,
focused: Boolean,
): JPanel {
val focus = selected || list.hasFocus() || focused
val fg = UIUtil.getListForeground(selected, focus)
val weak = if (selected) fg else UiStyle.Colors.weak()
val current = model.items.getOrNull(index)
val section = if (current === value) modelPickerSectionTitle(model.items, index) else null
return super.getListCellRendererComponent(list, value, index, selected, focused) as JPanel
}
background = list.background
top.background = list.background
wrap.update(list, selected, focus)
sep.caption = section
sep.setHideLine(index == 0)
top.isVisible = section != null
check.icon = if (value.key == active()) checked else empty
title.clear()
override fun update(
value: ModelPickerRow,
index: Int,
selected: Boolean,
focused: Boolean,
foreground: java.awt.Color,
weak: java.awt.Color,
) {
parts.title.clear()
val item = value.item
if (item == null) {
title.append(value.emptyText, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg))
badgeLabel.isVisible = false
byokLabel.isVisible = false
warn.isVisible = false
provider.isVisible = false
star.icon = EmptyIcon.ICON_16
top.invalidate()
return this
parts.title.append(value.emptyText, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, foreground))
parts.badgeLabel.isVisible = false
parts.byokLabel.isVisible = false
parts.warn.isVisible = false
parts.provider.isVisible = false
parts.star.icon = EmptyIcon.ICON_16
return
}
val name = ModelText.parts(item)
if (name.provider != null) {
title.append(name.provider, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak))
title.append(" ", SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak))
parts.title.append(name.provider, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak))
parts.title.append(" ", SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, weak))
}
title.append(name.model, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg))
parts.title.append(name.model, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, foreground))
warn.isVisible = ModelText.collectsData(item)
badgeLabel.isVisible = item.free && !item.byok
byokLabel.isVisible = item.byok
provider.isVisible = value.favorite
provider.text = item.providerName
provider.foreground = weak
provider.border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
parts.warn.isVisible = ModelText.collectsData(item)
parts.badgeLabel.isVisible = item.free && !item.byok
parts.byokLabel.isVisible = item.byok
parts.provider.isVisible = value.favorite
parts.provider.text = item.providerName
parts.provider.foreground = weak
parts.provider.border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
val fav = item.key in favorites()
star.icon = when {
parts.star.icon = when {
fav -> AllIcons.Nodes.Favorite
selected -> AllIcons.Nodes.NotFavoriteOnHover
else -> EmptyIcon.ICON_16
}
top.invalidate()
return this
}
internal fun starIcon(): Icon? = star.icon
internal fun starIcon(): Icon? = parts.star.icon
internal fun badgeVisible(): Boolean = badgeLabel.isVisible
internal fun badgeVisible(): Boolean = parts.badgeLabel.isVisible
internal fun badgeText(): String = badge.text
internal fun badgeText(): String = parts.badge.text
internal fun byokVisible(): Boolean = byokLabel.isVisible
internal fun byokVisible(): Boolean = parts.byokLabel.isVisible
internal fun warningVisible(): Boolean = warn.isVisible
internal fun warningVisible(): Boolean = parts.warn.isVisible
internal fun warningTooltip(): String? = warn.toolTipText
internal fun warningTooltip(): String? = parts.warn.toolTipText
private class BadgeLabel(icon: Icon) : JBLabel(icon)
private data class Parts(
val title: SimpleColoredComponent,
val badge: FilledBadgeIcon,
val badgeLabel: BadgeLabel,
val byokLabel: BadgeLabel,
val warn: JBLabel,
val provider: JBLabel,
val star: JBLabel,
val head: JPanel,
) {
companion object {
fun create(): Parts {
val title = SimpleColoredComponent()
val badge = FilledBadgeIcon(ModelText.freeLabel(), UiStyle.Badge.Highlight)
val badgeLabel = BadgeLabel(badge).apply {
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
val byok = FilledBadgeIcon("BYOK", UiStyle.Badge.Highlight)
val byokLabel = BadgeLabel(byok).apply {
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
val warn = JBLabel(DATA_COLLECTED).apply {
toolTipText = ModelText.dataCollected()
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
val provider = JBLabel()
val star = JBLabel().apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
}
val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
add(title)
add(warn)
add(badgeLabel)
add(byokLabel)
add(provider)
}
UiStyle.Components.transparent(title, head, warn, provider, star)
return Parts(title, badge, badgeLabel, byokLabel, warn, provider, star, head)
}
}
}
}
@@ -0,0 +1,118 @@
package ai.kilocode.client.ui.picker
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.CollectionListModel
import com.intellij.ui.GroupHeaderSeparator
import com.intellij.ui.NewUI
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Point
import java.awt.Rectangle
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JList
import javax.swing.JPanel
import javax.swing.ListCellRenderer
import javax.swing.SwingConstants
internal abstract class PickerListRenderer<T>(
private val model: CollectionListModel<T>,
private val checked: (T) -> Boolean,
private val sectionTitle: (List<T>, Int) -> String?,
protected val content: JComponent,
private val trailing: JComponent? = null,
) : JPanel(BorderLayout()), ListCellRenderer<T> {
companion object {
val checkedIcon: Icon = AllIcons.Actions.Checked
val emptyIcon: Icon = EmptyIcon.create(checkedIcon)
fun trailingClickZone(list: JList<*>, bounds: Rectangle, point: Point, width: Int): Boolean {
val size = JBUI.scale(width)
val inset = trailingInset(list)
if (list.componentOrientation.isLeftToRight) {
val right = bounds.x + bounds.width - inset
return point.x in (right - size)..right
}
val left = bounds.x + inset
return point.x in left..(left + size)
}
private fun trailingInset(list: JList<*>): Int {
if (!NewUI.isEnabled()) return 0
val inner = JBUI.CurrentTheme.Popup.Selection.innerInsets()
val edge = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get()
return edge + if (list.componentOrientation.isLeftToRight) inner.right else inner.left
}
}
private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets())
private val top = JPanel(BorderLayout()).apply {
border = JBUI.Borders.empty()
add(sep, BorderLayout.NORTH)
}
private val check = JBLabel().apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
}
private val row = JPanel(BorderLayout()).apply {
border = JBUI.Borders.empty(
UiStyle.Gap.md(),
UiStyle.Gap.lg(),
UiStyle.Gap.md(),
UiStyle.Gap.pad(),
)
add(check, BorderLayout.WEST)
add(content, BorderLayout.CENTER)
}
private val wrap = PickerRow()
init {
isOpaque = true
top.isOpaque = true
UiStyle.Components.transparent(row, check, content)
trailing?.let { UiStyle.Components.transparent(it) }
wrap.setContent(row, trailing)
add(top, BorderLayout.NORTH)
add(wrap, BorderLayout.CENTER)
}
override fun getListCellRendererComponent(
list: JList<out T>,
value: T,
index: Int,
selected: Boolean,
focused: Boolean,
): Component {
val focus = selected || list.hasFocus() || focused
val fg = UIUtil.getListForeground(selected, focus)
val weak = if (selected) fg else UiStyle.Colors.weak()
val current = model.items.getOrNull(index)
val section = if (current === value) sectionTitle(model.items, index) else null
background = list.background
top.background = list.background
wrap.update(list, selected, focus)
sep.caption = section
sep.setHideLine(index == 0)
top.isVisible = section != null
check.icon = if (checked(value)) checkedIcon else emptyIcon
update(value, index, selected, focus, fg, weak)
top.invalidate()
return this
}
protected abstract fun update(
value: T,
index: Int,
selected: Boolean,
focused: Boolean,
foreground: java.awt.Color,
weak: java.awt.Color,
)
}
@@ -0,0 +1,353 @@
package ai.kilocode.client.ui.picker
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.HoverIcon
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 com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.Disposable
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.openapi.util.Disposer
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.CollectionListModel
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.ListUtil
import com.intellij.ui.NewUI
import com.intellij.ui.ScrollPaneFactory
import com.intellij.ui.ScrollingUtil
import com.intellij.ui.SearchTextField
import com.intellij.ui.components.JBList
import com.intellij.ui.popup.AbstractPopup
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Cursor
import java.awt.Dimension
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JList
import javax.swing.JPanel
import javax.swing.JScrollPane
import javax.swing.KeyStroke
import javax.swing.ListSelectionModel
import javax.swing.ScrollPaneConstants
import javax.swing.SwingUtilities
import javax.swing.event.DocumentEvent
internal val popupBackground: Color
get() = if (NewUI.isEnabled()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground()
private val EXPAND: Icon = IconLoader.getIcon("/icons/expand.svg", PickerPopup::class.java)
private val COLLAPSE: Icon = IconLoader.getIcon("/icons/collapse.svg", PickerPopup::class.java)
internal class PickerPopup<T>(
private val anchor: JComponent,
private val placement: Placement,
private val rows: (String) -> List<T>,
private val model: CollectionListModel<T>,
private val renderer: PickerListRenderer<T>,
private val key: (T) -> Any? = { it as Any },
private val mode: Mode,
private val onPrimary: (T) -> Unit,
private val sectionTitle: (List<T>, Int) -> String? = { _, _ -> null },
private val trailingHit: ((JList<*>, java.awt.Rectangle, java.awt.Point) -> Boolean)? = null,
private val onTrailing: ((T) -> Unit)? = null,
private val search: Boolean = false,
private val toolbar: List<JComponent> = emptyList(),
private val details: JComponent? = null,
private val onPreview: (T?) -> Unit = {},
private val expandStateKey: String? = null,
private val minWidth: Int = 420,
private val maxWidth: Int = 760,
private val maxVisibleRows: Int = 10,
private val emptyListHeight: Int = 120,
private val emptyText: String = KiloBundle.message("model.picker.no.matches"),
) {
enum class Placement { ABOVE, BELOW, UNDERNEATH }
enum class Mode { Single, Multi }
private val props get() = PropertiesComponent.getInstance()
private var expanded = expandStateKey?.let { props.getBoolean(it, false) } ?: false
private val list = JBList(model).apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
isFocusable = !search
this.emptyText.text = this@PickerPopup.emptyText
background = popupBackground
border = JBUI.Borders.empty(PopupUtil.getListInsets(false, false))
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
cellRenderer = renderer
}
private val field = if (search) SearchTextField(false).apply {
textEditor.emptyText.text = KiloBundle.message("model.picker.search")
} else null
private val expand = details?.let {
HoverIcon().apply { cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) }
}
private lateinit var popup: JBPopup
private lateinit var content: JPanel
private lateinit var scroll: JScrollPane
private var shown = false
fun show(): JBPopup {
installSearch()
installKeys(list)
installMouse()
installExpand()
list.addListSelectionListener {
if (!it.valueIsAdjusting && expanded) preview()
}
ListUtil.installAutoSelectOnMouseMove(list)
ScrollingUtil.installActions(list)
val head = header()
scroll = ScrollPaneFactory.createScrollPane(list).apply {
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
background = popupBackground
viewport.background = popupBackground
viewport.isOpaque = true
}
content = JPanel(BorderLayout()).apply {
background = popupBackground
border = JBUI.Borders.empty()
add(head, BorderLayout.NORTH)
add(scroll, BorderLayout.CENTER)
details?.let { add(it, BorderLayout.EAST) }
}
PopupUtil.applyNewUIBackground(list)
list.background = popupBackground
field?.let {
AbstractPopup.customizeSearchFieldLook(it, true)
it.background = popupBackground
}
refresh()
syncExpand()
preview()
resize(head)
popup = JBPopupFactory.getInstance()
.createComponentPopupBuilder(content, field?.textEditor ?: list)
.setRequestFocus(true)
.setFocusable(true)
.setCancelOnClickOutside(true)
.setCancelKeyEnabled(true)
.setCancelOnWindowDeactivation(true)
.setLocateWithinScreenBounds(true)
.setResizable(false)
.setMovable(false)
.createPopup()
if (details is Disposable) Disposer.register(popup, details)
when (placement) {
Placement.ABOVE -> popup.show(PopupShowOptions.aboveComponent(anchor))
Placement.BELOW,
Placement.UNDERNEATH -> popup.showUnderneathOf(anchor)
}
shown = true
SwingUtilities.invokeLater {
field?.let {
it.textEditor.requestFocusInWindow()
it.selectText()
} ?: list.requestFocusInWindow()
list.selectedIndex.takeIf { it >= 0 }?.let(list::ensureIndexIsVisible)
}
return popup
}
fun refresh(prefer: Any? = selectedKey(), at: Int? = null) {
val data = rows(field?.text.orEmpty())
model.replaceAll(data)
val idx = at?.takeIf { it in data.indices }
?: prefer?.let { value -> data.indexOfFirst { it == value || key(it) == value }.takeIf { it >= 0 } }
?: data.indices.firstOrNull()
?: -1
if (idx >= 0) choose(idx) else list.clearSelection()
preview()
}
fun repaint() {
list.repaint()
}
private fun header(): JComponent {
val ins = PopupUtil.getListInsets(false, false)
val pad = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get()
val head = JPanel(BorderLayout()).apply {
background = popupBackground
border = JBUI.Borders.empty(ins.top, ins.left, ins.bottom, pad)
}
field?.let { head.add(it.align(HAlign.TRACK, VAlign.CENTER), BorderLayout.CENTER) }
val actions = toolbar + listOfNotNull(expand)
if (actions.isNotEmpty()) {
val bar = Stack.horizontal(JBUI.CurrentTheme.ActionsList.elementIconGap()).apply {
actions.forEach { next(it) }
}
head.add(bar.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST)
}
return head
}
private fun installSearch() {
val editor = field?.textEditor ?: return
editor.document.addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
refresh()
}
})
editor.registerKeyboardAction({ move(-1) }, KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), JComponent.WHEN_FOCUSED)
editor.registerKeyboardAction({ move(1) }, KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), JComponent.WHEN_FOCUSED)
installKeys(editor)
}
private fun installKeys(component: JComponent) {
component.registerKeyboardAction({ list.selectedValue?.let(::primary) }, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), JComponent.WHEN_FOCUSED)
component.registerKeyboardAction({ popup.cancel() }, KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), JComponent.WHEN_FOCUSED)
if (mode == Mode.Multi) {
component.registerKeyboardAction({ list.selectedValue?.let(::primary) }, KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), JComponent.WHEN_FOCUSED)
return
}
if (onTrailing != null) {
component.registerKeyboardAction({ list.selectedValue?.let(::trailing) }, KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, InputEvent.SHIFT_DOWN_MASK), JComponent.WHEN_FOCUSED)
}
}
private fun installMouse() {
list.addMouseListener(object : MouseAdapter() {
override fun mouseReleased(e: MouseEvent) {
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
val idx = list.locationToIndex(e.point).takeIf { it >= 0 } ?: return
val bounds = list.getCellBounds(idx, idx) ?: return
if (!bounds.contains(e.point)) return
val value = model.getElementAt(idx)
if (trailingHit?.invoke(list, bounds, e.point) == true) {
trailing(value)
e.consume()
return
}
primary(value)
e.consume()
}
})
}
private fun installExpand() {
expand?.addActionListener { setExpanded(!expanded) }
}
private fun primary(value: T) {
onPrimary(value)
if (mode == Mode.Single) {
popup.closeOk(null)
return
}
refresh(at = list.selectedIndex)
list.repaint()
}
private fun trailing(value: T) {
val block = onTrailing ?: return
val idx = list.selectedIndex
block(value)
refresh(at = idx)
list.getCellBounds(list.selectedIndex, list.selectedIndex)?.let(list::repaint)
preview()
}
private fun choose(idx: Int) {
list.selectedIndex = idx
ScrollingUtil.ensureIndexIsVisible(list, idx, 0)
}
private fun move(step: Int) {
val size = model.size
if (size <= 0) return
val cur = list.selectedIndex.takeIf { it >= 0 } ?: 0
choose((cur + step).coerceIn(0, size - 1))
}
private fun preview() {
onPreview(list.selectedValue)
}
private fun setExpanded(value: Boolean) {
if (expanded == value) return
expanded = value
expandStateKey?.let { props.setValue(it, value.toString()) }
if (!expanded) list.clearSelection()
syncExpand()
preview()
resize(content.getComponent(0) as JComponent)
}
private fun syncExpand() {
val details = details ?: return
val expand = expand ?: return
expand.icon = if (expanded) COLLAPSE else EXPAND
expand.toolTipText = if (expanded) {
KiloBundle.message("model.picker.details.minimize")
} else {
KiloBundle.message("model.picker.details.maximize")
}
expand.accessibleContext.accessibleName = expand.toolTipText
details.isVisible = expanded
}
private fun resize(head: JComponent) {
val size = computeInitialPopupSize(list, scroll, head, expanded)
content.preferredSize = size
if (expanded && details != null) {
details.preferredSize = Dimension(size.width - scroll.preferredSize.width, scroll.preferredSize.height)
}
content.revalidate()
content.repaint()
if (shown) popup.setSize(size)
}
private fun selectedKey(): Any? = list.selectedValue?.let(key)
private fun computeInitialPopupSize(list: JList<T>, scroll: JScrollPane, head: JComponent, expanded: Boolean): Dimension {
val width = maxOf(computeListPreferredWidth(list), head.preferredSize.width.coerceIn(JBUI.scale(minWidth), JBUI.scale(maxWidth)))
list.fixedCellWidth = width
val height = computeListPreferredHeight(list)
val bar = if (list.model.size > maxVisibleRows) scroll.verticalScrollBar.preferredSize.width else 0
val listWidth = width + bar
val detailWidth = if (expanded && details != null) width else 0
val size = Dimension(listWidth + detailWidth, head.preferredSize.height + height)
scroll.preferredSize = Dimension(listWidth, height)
return size
}
private fun computeListPreferredWidth(list: JList<T>): Int {
val renderer = list.cellRenderer ?: return JBUI.scale(minWidth)
val model = list.model
val max = (0 until model.size).maxOfOrNull { idx ->
val value = model.getElementAt(idx)
renderer.getListCellRendererComponent(list, value, idx, false, false).preferredSize.width
} ?: 0
val ins = list.insets
return (max + ins.left + ins.right).coerceIn(JBUI.scale(minWidth), JBUI.scale(maxWidth))
}
private fun computeListPreferredHeight(list: JList<T>): Int {
val renderer = list.cellRenderer ?: return JBUI.scale(emptyListHeight)
val model = list.model
val count = model.size.coerceAtMost(maxVisibleRows)
if (count <= 0) return JBUI.scale(emptyListHeight)
val height = (0 until count).sumOf { idx ->
val value = model.getElementAt(idx)
renderer.getListCellRendererComponent(list, value, idx, false, false).preferredSize.height
}
val ins = list.insets
return height + ins.top + ins.bottom
}
}