fix(jetbrains): address model picker review feedback

This commit is contained in:
kirillk
2026-07-01 20:10:44 -04:00
parent 5dd4aaa0d9
commit 3ff5b0954e
11 changed files with 357 additions and 109 deletions
@@ -1098,8 +1098,8 @@ object KiloCliDataParser {
inputPrice = obj.num("inputPrice"),
outputPrice = obj.num("outputPrice"),
contextLength = obj.long("contextLength"),
releaseDate = obj.str("releaseDate"),
latest = obj.flagOrNull("latest"),
releaseDate = obj.str("release_date"),
latest = null,
attachment = cap.bool("attachment"),
reasoning = cap.bool("reasoning"),
temperature = cap.bool("temperature"),
@@ -1380,8 +1380,7 @@ class KiloCliDataParserTest {
"inputPrice": 0.25,
"outputPrice": 1.5,
"contextLength": 256000,
"releaseDate": "2026-06-01",
"latest": true,
"release_date": "2026-06-01",
"capabilities": {
"reasoning": true,
"input": {"text": true, "image": true, "audio": false, "video": true, "pdf": true}
@@ -1402,7 +1401,7 @@ class KiloCliDataParserTest {
assertEquals(1.5, model.outputPrice)
assertEquals(256000L, model.contextLength)
assertEquals("2026-06-01", model.releaseDate)
assertEquals(true, model.latest)
assertNull(model.latest)
assertEquals(0.05, model.cost?.cache?.read)
assertEquals(true, model.capabilities?.reasoning)
assertEquals(true, model.capabilities?.input?.image)
@@ -1,9 +1,16 @@
package ai.kilocode.client.session.ui.model
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.md.MdView
import ai.kilocode.client.ui.md.MdViewFactory
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBUI
@@ -11,18 +18,21 @@ import com.intellij.util.ui.UIUtil
import com.intellij.xml.util.XmlStringUtil
import java.awt.BorderLayout
import java.awt.Cursor
import java.awt.FlowLayout
import java.text.NumberFormat
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
internal class ModelDetailsPanel(
private val favorites: () -> Set<String>,
private val toggle: (ModelPicker.Item) -> Unit,
) : JPanel(BorderLayout()) {
) : JPanel(BorderLayout()), Disposable {
private val empty = JBLabel(KiloBundle.message("model.picker.details.empty")).apply {
foreground = UIUtil.getContextHelpForeground()
}
private val title = JBLabel().apply { font = UiStyle.Fonts.bold() }
private val provider = JBLabel().apply { foreground = UIUtil.getContextHelpForeground() }
private val star = JBLabel().apply {
@@ -30,9 +40,38 @@ internal class ModelDetailsPanel(
horizontalAlignment = JBLabel.CENTER
verticalAlignment = JBLabel.CENTER
}
private val badges = Stack.horizontal(UiStyle.Gap.xs())
private val head = JPanel(BorderLayout()).apply {
add(Stack.vertical(UiStyle.Gap.xs()).next(title).next(provider), BorderLayout.CENTER)
add(star, BorderLayout.EAST)
}
private val free = badge(ModelText.freeLabel())
private val byok = badge("BYOK")
private val data = badge(KiloBundle.message("model.picker.dataCollected"))
private val latest = badge(KiloBundle.message("model.picker.details.latest"))
private val badges = Stack.horizontal(UiStyle.Gap.xs()).next(free).next(byok).next(data).next(latest)
private val props = RowsSection(KiloBundle.message("model.picker.details.properties"))
private val bench = RowsSection(KiloBundle.message("model.picker.details.terminalBench"))
private val caps = TagsSection(KiloBundle.message("model.picker.details.capabilities"))
private val desc = MarkdownSection(KiloBundle.message("model.picker.details.description"))
private val routeText = JBLabel().apply {
foreground = UIUtil.getLabelForeground()
setAllowAutoWrapping(true)
}
private val route = Stack.vertical(UiStyle.Gap.xs())
.next(heading(KiloBundle.message("model.picker.details.autoRouting")))
.next(routeText)
private val ids = RowsSection(KiloBundle.message("model.picker.details.ids"))
private val body = Stack.vertical(UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.md(), UiStyle.Gap.lg(), UiStyle.Gap.md(), UiStyle.Gap.lg())
border = JBUI.Borders.empty(UiStyle.Gap.md(), UiStyle.Gap.lg(), UiStyle.Gap.md(), UiStyle.Gap.xl())
next(empty)
next(head)
next(badges)
next(props.root)
next(bench.root)
next(caps.root)
next(desc.root)
next(route)
next(ids.root)
}
private val scroll = JBScrollPane(body).apply {
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
@@ -53,55 +92,68 @@ internal class ModelDetailsPanel(
}
}
})
showEmpty()
}
fun update(value: ModelPicker.Item?) {
item = value
body.removeAll()
if (value == null) {
body.next(JBLabel(KiloBundle.message("model.picker.details.empty")).apply {
foreground = UIUtil.getContextHelpForeground()
})
showEmpty()
refresh()
return
}
title.text = ModelText.parts(value).model
provider.text = value.providerName
star.icon = if (value.key in favorites()) AllIcons.Nodes.Favorite else AllIcons.Nodes.NotFavoriteOnHover
star.toolTipText = if (value.key in favorites()) {
empty.isVisible = false
head.isVisible = true
title.sync(ModelText.parts(value).model)
provider.sync(value.providerName)
syncStar(value)
syncBadges(value)
props.update(properties(value))
bench.update(bench(value))
caps.update(capabilities(value))
desc.update(value.options?.description?.takeIf { it.isNotBlank() }?.let(::descriptionText))
syncRouting(value)
ids.update(listOf(
KiloBundle.message("model.picker.details.providerId") to value.provider,
KiloBundle.message("model.picker.details.modelId") to value.id,
))
refresh()
}
private fun showEmpty() {
empty.isVisible = true
head.isVisible = false
badges.isVisible = false
props.update(emptyList())
bench.update(emptyList())
caps.update(emptyList())
desc.update(null)
route.isVisible = false
ids.update(emptyList())
}
private fun syncStar(value: ModelPicker.Item) {
val selected = value.key in favorites()
star.icon = if (selected) AllIcons.Nodes.Favorite else AllIcons.Nodes.NotFavoriteOnHover
star.toolTipText = if (selected) {
KiloBundle.message("model.picker.favorite.remove")
} else {
KiloBundle.message("model.picker.favorite.add")
}
badges.removeAll()
if (value.free && !value.byok) badges.next(badge(ModelText.freeLabel()))
if (value.byok) badges.next(badge("BYOK"))
if (ModelText.collectsData(value)) badges.next(badge(KiloBundle.message("model.picker.dataCollected")))
if (value.latest == true) badges.next(badge(KiloBundle.message("model.picker.details.latest")))
body.next(header())
if (badges.componentCount > 0) body.next(badges)
grid(value)?.let(body::next)
bench(value)?.let(body::next)
capabilities(value)?.let(body::next)
description(value)?.let(body::next)
routing(value)?.let(body::next)
body.next(section(KiloBundle.message("model.picker.details.ids"), listOf(
KiloBundle.message("model.picker.details.providerId") to value.provider,
KiloBundle.message("model.picker.details.modelId") to value.id,
)))
refresh()
}
private fun header() = JPanel(BorderLayout()).apply {
add(Stack.vertical(UiStyle.Gap.xs()).next(title).next(provider), BorderLayout.CENTER)
add(star, BorderLayout.EAST)
private fun syncBadges(value: ModelPicker.Item) {
free.isVisible = value.free && !value.byok
byok.isVisible = value.byok
data.isVisible = ModelText.collectsData(value)
latest.isVisible = value.latest == true
badges.isVisible = free.isVisible || byok.isVisible || data.isVisible || latest.isVisible
}
private fun grid(item: ModelPicker.Item): JComponent? {
private fun properties(item: ModelPicker.Item): List<Pair<String, String>> {
val ctx = item.limit?.context?.takeIf { it > 0 } ?: item.contextLength?.takeIf { it > 0 }
val rows = buildList {
return buildList {
item.releaseDate?.let { add(KiloBundle.message("model.picker.details.released") to date(it)) }
if (!item.free) {
item.cost?.let { cost ->
@@ -116,21 +168,19 @@ internal class ModelDetailsPanel(
}
ctx?.let { add(KiloBundle.message("model.picker.details.context") to context(it)) }
}
if (rows.isEmpty()) return null
return section(KiloBundle.message("model.picker.details.properties"), rows)
}
private fun bench(item: ModelPicker.Item): JComponent? {
val bench = item.terminalBench ?: return null
return section(KiloBundle.message("model.picker.details.terminalBench"), listOf(
private fun bench(item: ModelPicker.Item): List<Pair<String, String>> {
val bench = item.terminalBench ?: return emptyList()
return listOf(
KiloBundle.message("model.picker.details.completion") to percent(bench.overallScore),
KiloBundle.message("model.picker.details.costAttempt") to attempt(bench.avgAttemptCostUsd),
))
)
}
private fun capabilities(item: ModelPicker.Item): JComponent? {
private fun capabilities(item: ModelPicker.Item): List<String> {
val cap = item.capabilities
val values = buildList {
return buildList {
if (cap?.reasoning == true || item.reasoning) add(KiloBundle.message("model.picker.details.reasoning"))
val input = cap?.input
if (input?.text == true) add(KiloBundle.message("model.picker.details.modality.text"))
@@ -140,51 +190,141 @@ internal class ModelDetailsPanel(
if (input?.pdf == true) add(KiloBundle.message("model.picker.details.modality.pdf"))
if (item.attachment) add(KiloBundle.message("model.picker.details.attachments"))
}
if (values.isEmpty()) return null
return section(KiloBundle.message("model.picker.details.capabilities"), values.map { "" to it })
}
private fun description(item: ModelPicker.Item): JComponent? {
val text = item.options?.description?.takeIf { it.isNotBlank() } ?: return null
return Stack.vertical(UiStyle.Gap.xs())
.next(heading(KiloBundle.message("model.picker.details.description")))
.next(JBLabel(XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text))).apply {
foreground = UIUtil.getLabelForeground()
setAllowAutoWrapping(true)
})
}
private fun routing(item: ModelPicker.Item): JComponent? {
val models = item.autoRouting?.models?.takeIf { it.isNotEmpty() } ?: return null
return Stack.vertical(UiStyle.Gap.xs())
.next(heading(KiloBundle.message("model.picker.details.autoRouting")))
.next(JBLabel(XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(models.joinToString("\n")))).apply {
foreground = UIUtil.getLabelForeground()
setAllowAutoWrapping(true)
})
}
private fun section(title: String, rows: List<Pair<String, String>>) = Stack.vertical(UiStyle.Gap.xs()).apply {
next(heading(title))
rows.forEach { (label, value) -> next(row(label, value)) }
}
private fun row(label: String, value: String) = JPanel(BorderLayout()).apply {
if (label.isNotBlank()) add(JBLabel(label).apply { foreground = UIUtil.getContextHelpForeground() }, BorderLayout.WEST)
add(JBLabel(value).apply { foreground = UIUtil.getLabelForeground() }, BorderLayout.EAST)
}
private fun heading(value: String) = JBLabel(value).apply { font = UiStyle.Fonts.bold() }
private fun badge(value: String) = JBLabel(value).apply {
border = JBUI.Borders.empty(UiStyle.Gap.xs(), UiStyle.Gap.sm(), UiStyle.Gap.xs(), UiStyle.Gap.sm())
foreground = UIUtil.getLabelForeground()
private fun syncRouting(item: ModelPicker.Item) {
val models = item.autoRouting?.models?.takeIf { it.isNotEmpty() }
route.isVisible = models != null
if (models == null) return
routeText.sync(XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(models.joinToString("\n"))))
}
private fun refresh() {
body.revalidate()
body.repaint()
}
override fun dispose() {
desc.dispose()
}
}
private class RowsSection(title: String) {
private val rows = Stack.vertical(UiStyle.Gap.xs())
private val pool = mutableListOf<DetailRow>()
val root = Stack.vertical(UiStyle.Gap.xs())
.next(heading(title))
.next(rows)
fun update(values: List<Pair<String, String>>) {
root.isVisible = values.isNotEmpty()
values.forEachIndexed { idx, value ->
val row = row(idx)
row.update(value.first, value.second)
row.isVisible = true
}
for (idx in values.size until pool.size) {
pool[idx].isVisible = false
}
rows.revalidate()
rows.repaint()
}
private fun row(idx: Int): DetailRow {
pool.getOrNull(idx)?.let { return it }
val row = DetailRow()
pool.add(row)
rows.next(row)
return row
}
}
private class DetailRow : JPanel(BorderLayout()) {
private val name = JBLabel().apply { foreground = UIUtil.getContextHelpForeground() }
private val value = JBLabel().apply { foreground = UIUtil.getLabelForeground() }
init {
add(name, BorderLayout.WEST)
add(value, BorderLayout.EAST)
}
fun update(label: String, text: String) {
name.isVisible = label.isNotBlank()
name.sync(label)
value.sync(text)
}
}
private class TagsSection(title: String) {
private val tags = TagPanel()
private val pool = mutableListOf<JBLabel>()
val root = Stack.vertical(UiStyle.Gap.xs())
.next(heading(title))
.next(tags)
fun update(values: List<String>) {
root.isVisible = values.isNotEmpty()
values.forEachIndexed { idx, value ->
val tag = tag(idx)
tag.icon = FilledBadgeIcon(value, tagBackground(idx), UiStyle.Colors.fg())
tag.toolTipText = value
tag.isVisible = true
}
for (idx in values.size until pool.size) {
pool[idx].isVisible = false
}
tags.revalidate()
tags.repaint()
}
private fun tag(idx: Int): JBLabel {
pool.getOrNull(idx)?.let { return it }
val tag = JBLabel()
pool.add(tag)
tags.add(tag)
return tag
}
}
private class MarkdownSection(title: String) : Disposable {
val root = Stack.vertical(UiStyle.Gap.xs()).next(heading(title))
private var view: MdView? = null
fun update(text: String?) {
root.isVisible = text != null
if (text == null) return
val md = view ?: MdViewFactory.create(SessionEditorStyle.current()).apply {
opaque = false
addLinkListener { BrowserUtil.browse(it.href) }
}.also {
view = it
root.next(it.component)
}
md.set(text)
}
override fun dispose() {
view?.let(Disposer::dispose)
view = null
}
}
private class TagPanel : JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), UiStyle.Gap.xs())) {
init {
isOpaque = false
}
}
private fun heading(value: String) = JBLabel(value).apply { font = UiStyle.Fonts.bold() }
private fun badge(value: String) = JBLabel(value).apply {
border = JBUI.Borders.empty(UiStyle.Gap.xs(), UiStyle.Gap.sm(), UiStyle.Gap.xs(), UiStyle.Gap.sm())
foreground = UIUtil.getLabelForeground()
}
private fun JBLabel.sync(value: String) {
if (text == value) return
text = value
}
private fun context(value: Long): String {
@@ -200,13 +340,13 @@ private fun price(value: Double): String {
}
private fun cached(input: Double, read: Double?): String {
if (read != null && read > 0.0) return price(read)
if (read != null) return price(read)
if (input == 0.0) return price(0.0)
return KiloBundle.message("model.picker.details.notSupported")
}
private fun average(input: Double, output: Double, read: Double?): Double {
if (read != null && read > 0.0) return read * 0.7 + input * 0.2 + output * 0.1
if (read != null) return read * 0.7 + input * 0.2 + output * 0.1
return input * 0.9 + output * 0.1
}
@@ -218,6 +358,20 @@ private fun date(value: String): String = runCatching {
LocalDate.parse(value).format(DateTimeFormatter.ofPattern("MMM yyyy"))
}.getOrDefault(value)
private fun descriptionText(value: String): String = value
.replace(Regex("(?<![\\[<(])\\b(https?://[^\\s<>()`\"']+)([),.;!?])?")) { match ->
val url = match.groupValues[1]
val tail = match.groupValues.getOrNull(2).orEmpty()
"[$url]($url)$tail"
}
private fun tagBackground(index: Int) = when (index % 4) {
0 -> UiStyle.Colors.badgeBg()
1 -> UiStyle.Colors.blend(UiStyle.Colors.contentBackground(), UiStyle.Colors.fg(), 0.12f)
2 -> UiStyle.Colors.blend(UiStyle.Colors.contentBackground(), JBUI.CurrentTheme.Link.Foreground.ENABLED, 0.18f)
else -> UiStyle.Colors.blend(UiStyle.Colors.contentBackground(), UiStyle.Colors.activityBadgeBg(), 0.18f)
}
private fun format(value: Double): String = NumberFormat.getNumberInstance(Locale.getDefault()).apply {
maximumFractionDigits = if (value % 1.0 == 0.0) 0 else 1
}.format(value)
@@ -9,8 +9,10 @@ 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 com.intellij.icons.AllIcons
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
@@ -33,7 +35,7 @@ import java.awt.BorderLayout
import java.awt.Color
import java.awt.Cursor
import java.awt.Dimension
import java.awt.FlowLayout
import java.awt.GridBagLayout
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
@@ -48,6 +50,7 @@ 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()
@@ -56,6 +59,8 @@ 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() {
@@ -185,27 +190,28 @@ class ModelPicker : PickerButton() {
val search = SearchTextField(false).apply {
textEditor.emptyText.text = KiloBundle.message("model.picker.search")
}
var refreshFavorite: (Item) -> Unit = {}
val details = ModelDetailsPanel(
favorites = { favoriteKeys() },
toggle = { item ->
onFavoriteToggle(item)
},
toggle = { refreshFavorite(it) },
).apply {
background = popupBackground
}
val expand = JBLabel().apply {
val expand = HoverIcon().apply {
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
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(JPanel(FlowLayout(FlowLayout.RIGHT, 0, 0)).apply {
background = popupBackground
add(expand)
}, BorderLayout.EAST)
add(expandWrap, BorderLayout.EAST)
}
lateinit var popup: JBPopup
@@ -222,7 +228,7 @@ class ModelPicker : PickerButton() {
}
fun syncExpand() {
expand.icon = if (expanded) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
expand.icon = if (expanded) COLLAPSE else EXPAND
expand.toolTipText = if (expanded) {
KiloBundle.message("model.picker.details.minimize")
} else {
@@ -290,6 +296,12 @@ class ModelPicker : PickerButton() {
syncDetails()
}
refreshFavorite = { item ->
onFavoriteToggle(item)
sync(prefer = item.key)
list.repaint()
}
fun resize() {
val size = computeInitialPopupSize(list, scroll, head, expanded)
content.preferredSize = size
@@ -376,11 +388,7 @@ class ModelPicker : PickerButton() {
activate(value)
}
})
expand.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
setExpanded(!expanded)
}
})
expand.addActionListener { setExpanded(!expanded) }
list.addListSelectionListener {
if (!it.valueIsAdjusting && expanded) syncDetails()
}
@@ -424,6 +432,7 @@ class ModelPicker : PickerButton() {
.setResizable(false)
.setMovable(false)
.createPopup()
Disposer.register(popup, details)
when (placement) {
Placement.ABOVE -> popup.show(PopupShowOptions.aboveComponent(this))
@@ -195,6 +195,7 @@ internal class ModelsSettingsUi(
autoRouting = model.autoRouting,
terminalBench = model.terminalBench,
reasoning = model.reasoning,
attachment = model.attachment,
mayTrainOnYourPrompts = model.mayTrainOnYourPrompts,
)
if (!includeSmall && ModelText.small(item)) return@mapNotNull null
@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.666 8.33398H11.666V3.33398" stroke="#6C707E" stroke-linecap="square"/>
<path d="M8.33398 16.666V11.666H3.33398" stroke="#6C707E" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 275 B

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.666 8.33398H11.666V3.33398" stroke="#CED0D6" stroke-linecap="square"/>
<path d="M8.33398 16.666V11.666H3.33398" stroke="#CED0D6" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 275 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.58301 10.4163V15.4163H9.58301M10.4163 4.58301H15.4163V9.58301" stroke="#6C707E" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.58301 10.4163V15.4163H9.58301M10.4163 4.58301H15.4163V9.58301" stroke="#CED0D6" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -14,6 +14,7 @@ import com.intellij.ide.util.PropertiesComponent
import com.intellij.ui.CollectionListModel
import com.intellij.ui.NewUI
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBHtmlPane
import com.intellij.ui.components.JBList
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.EmptyIcon
@@ -309,7 +310,7 @@ class ModelPickerTest : BasePlatformTestCase() {
reasoning = true,
input = ModelInputCapabilitiesDto(text = true, image = true, pdf = true),
),
options = ModelOptionsDto("Fast routed model"),
options = ModelOptionsDto("Fast routed model https://kilocode.ai"),
autoRouting = ModelAutoRoutingDto(listOf("openai/gpt", "anthropic/claude")),
terminalBench = ModelTerminalBenchDto(0.73, 1.25),
)
@@ -320,10 +321,55 @@ class ModelPickerTest : BasePlatformTestCase() {
assertTrue(panel.labels().any { it.contains("BYOK") })
assertTrue(panel.labels().any { it.contains("Terminal Bench") })
assertTrue(panel.labels().any { it.contains("73.0%") })
assertTrue(panel.labels().any { it.contains("Fast routed model") })
assertTrue(panel.html().any { it.contains("Fast routed model") })
assertTrue(panel.html().any { it.contains("href=\"https://kilocode.ai\"") })
assertTrue(panel.labels().any { it.contains("openai/gpt") })
}
fun `test details panel shows free cached reads`() {
val panel = ModelDetailsPanel(
favorites = { emptySet() },
toggle = {},
)
panel.update(ModelPicker.Item(
id = "paid",
display = "Paid",
provider = "kilo",
providerName = "Kilo",
cost = ModelCostDto(1.0, 2.0, ModelCacheCostDto(0.0, 0.5)),
))
assertTrue(panel.labels().any { it == "Free" })
assertFalse(panel.labels().any { it == "Not supported" })
}
fun `test details panel updates retained description component`() {
val panel = ModelDetailsPanel(
favorites = { emptySet() },
toggle = {},
)
val item = ModelPicker.Item(
id = "first",
display = "First",
provider = "kilo",
providerName = "Kilo",
cost = ModelCostDto(1.0, 2.0, ModelCacheCostDto(0.1, 0.5)),
options = ModelOptionsDto("First https://kilocode.ai"),
)
panel.update(item)
val count = panel.treeSize()
val html = panel.htmlPanes().single()
panel.update(item.copy(id = "second", display = "Second", options = ModelOptionsDto("Second https://kilocode.ai/docs")))
assertEquals(count, panel.treeSize())
assertSame(html, panel.htmlPanes().single())
assertTrue(panel.labels().any { it.contains("Second") })
assertTrue(panel.html().any { it.contains("Second") })
assertFalse(panel.html().any { it.contains("First") })
}
fun `test selected paid model with training flag indicates data collection`() {
val picker = ModelPicker()
@@ -532,4 +578,21 @@ class ModelPickerTest : BasePlatformTestCase() {
if (this !is Container) return own
return own + components.flatMap { it.labels() }
}
private fun Component.html(): List<String> {
val own = if (this is JBHtmlPane) listOf(text.orEmpty()) else emptyList()
if (this !is Container) return own
return own + components.flatMap { it.html() }
}
private fun Component.htmlPanes(): List<JBHtmlPane> {
val own = if (this is JBHtmlPane) listOf(this) else emptyList()
if (this !is Container) return own
return own + components.flatMap { it.htmlPanes() }
}
private fun Component.treeSize(): Int {
if (this !is Container) return 1
return 1 + components.sumOf { it.treeSize() }
}
}
@@ -310,13 +310,21 @@ class ModelsSettingsUiTest : BasePlatformTestCase() {
}
}
fun `test settings picker keeps model attachment metadata`() {
val panel = requireUi()
edt {
assertTrue(pickers(panel).first().selectedForTest()?.attachment == true)
}
}
private fun providers(): ProvidersDto = ProvidersDto(
providers = listOf(
ProviderDto(
id = "kilo",
name = "Kilo",
models = mapOf(
"old" to ModelDto(id = "old", name = "Old"),
"old" to ModelDto(id = "old", name = "Old", attachment = true),
"new" to ModelDto(id = "new", name = "New"),
),
),