mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
fix(jetbrains): address model picker review feedback
This commit is contained in:
+2
-2
@@ -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"),
|
||||
|
||||
+2
-3
@@ -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)
|
||||
|
||||
+236
-82
@@ -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)
|
||||
|
||||
+28
-19
@@ -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))
|
||||
|
||||
+1
@@ -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 |
+65
-2
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -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"),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user