diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index ec132734b6..51653491d1 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -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"), diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 349947afa1..e017df798e 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt index 154204528a..b421358986 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelDetailsPanel.kt @@ -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, 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> { 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> { + 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 { 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>) = 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() + val root = Stack.vertical(UiStyle.Gap.xs()) + .next(heading(title)) + .next(rows) + + fun update(values: List>) { + 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() + val root = Stack.vertical(UiStyle.Gap.xs()) + .next(heading(title)) + .next(tags) + + fun update(values: List) { + 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("(?()`\"']+)([),.;!?])?")) { 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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index 04340499ef..b7b8ac1df3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -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)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index be02b8b79c..ade8dc0c6d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/collapse.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/collapse.svg new file mode 100644 index 0000000000..4b27beb51d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/collapse.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/collapse_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/collapse_dark.svg new file mode 100644 index 0000000000..2faa8a4cec --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/collapse_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/expand.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/expand.svg new file mode 100644 index 0000000000..6893283c90 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/expand.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/expand_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/expand_dark.svg new file mode 100644 index 0000000000..0b47a0df81 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/expand_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt index ee7b736b88..fbc016bba2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt @@ -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 { + 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 { + 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() } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt index d2decf1199..5426e03ce4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUiTest.kt @@ -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"), ), ),