.kilo/plans/1784058434633-consolidate-jetbrains-model-pickers.md
Remove duplication between the two list-picker popups in the JetBrains frontend by extracting a generic base, and make both consume it:
session/ui/model/ModelPicker.kt (showPopup) — single-select
commit, search field, expand/collapse details panel, sections, per-row favorite (star)
toggle.settings/providers/ProvidersSettingsUi.kt
(showModelPopup inside CustomProviderDialog) — multi-select toggle, no search, no
details, selection stored in the models text field.The base must expose configurable options; the provider picker enables: multi-select, no expanded/details view, and extra header toolbar buttons ("Select All" / "Deselect All").
All work is under packages/kilo-jetbrains/frontend/. No backend/RPC/DTO changes. Files are
Kilo-owned (no kilocode_change markers).
PickerPopup<T> base; both pickers become thin configs.checked(row)
to render check state and onPrimary(row) on activation. Provider keeps the models text
field as source of truth; prompt keeps its favorites callbacks.CustomModelRow.selectAll row entirely.ModelPickerRenderer and the provider
renderer extend it. Generalize the favorite hit-zone into a trailing click-zone helper.ai/kilocode/client/ui/picker/ with PickerPopup<T> and
PickerListRenderer<T>. PickerRow and ModelSearch stay where they are and are reused.ui/picker/PickerPopup.kt — generic popupOwns: JBList<T> + CollectionListModel<T>, createComponentPopupBuilder with the shared
flags (requestFocus/focusable/cancelOnClickOutside/cancelKeyEnabled/cancelOnWindowDeactivation/
locateWithinScreenBounds, non-resizable, non-movable), popupBackground helper (move the
duplicated NewUI -> Popup.BACKGROUND else getListBackground() here), header assembly,
mouse hit-testing, keyboard bindings, auto-select-on-move + scrolling install, and size
computation.
Config (constructor params / vars, defaults match current behavior):
anchor: JComponent, placement: Placement (ABOVE / BELOW / UNDERNEATH). Prompt uses
PopupShowOptions.aboveComponent / showUnderneathOf; provider uses showUnderneathOf(pick).rows: (query: String) -> List<T> — rebuilt when the search text changes. Provider passes a
function that ignores query.renderer: PickerListRenderer<T>.checked: (T) -> Boolean — drives the row check icon (single: row.key == active; multi:
isSelected(row)).sectionTitle: (rows: List<T>, index: Int) -> String? — optional; prompt supplies
modelPickerSectionTitle, provider passes { _, _ -> null }.mode: Mode = Single | Multi.
onPrimary(row) then popup.closeOk(null).onPrimary(row), repaint, stay open.onPrimary: (T) -> Unit. (Prompt decides activate-vs-clear inside based on row.item == null;
provider toggles membership in the text field.)trailingHit: (list, bounds, point) -> Boolean and
onTrailing: (T) -> Unit. Prompt supplies favorite hit-zone + favorite toggle; provider omits.
Keyboard: in Single mode Shift+SPACE triggers trailing when present.search: Boolean — show SearchTextField in header CENTER. Prompt true; provider false.toolbar: List<JComponent> — extra header buttons placed in header WEST. Provider passes
Select All / Deselect All; prompt passes empty.details: JComponent? + onPreview: (T?) -> Unit +
expandStateKey: String?. When non-null, base shows the expand HoverIcon, EAST details
panel, persists expanded state via PropertiesComponent, reserves details width when
expanded, and Disposer.register(popup, details) if details is Disposable. Provider passes
null (no expand toggle, no details).minWidth, maxWidth, maxVisibleRows, emptyListHeight (defaults = current
ModelPicker constants 420/760/10/120). Provider passes minWidth = 320 and a maxWidth
that preserves today's look. Reuse the existing computeInitialPopupSize /
computeListPreferredWidth / computeListPreferredHeight logic, moved into the base and
parameterized by these constants + optional details width.Header layout: BorderLayout with toolbar row WEST, search CENTER (empty when
search=false), expand HoverIcon EAST (only when details present). Preserve
AbstractPopup.customizeSearchFieldLook and background wiring.
Keyboard (registered on both the list and, when present, the search editor):
UP/DOWN → move selection (search field only; list uses ScrollingUtil).ENTER → primary on selected row.ESC → popup.cancel().Shift+SPACE → trailing on selected row.SPACE → primary (toggle) on selected row.Mouse (mouseReleased, UIUtil.isActionClick): resolve row via locationToIndex +
getCellBounds/contains; if trailing present and trailingHit → onTrailing + consume;
else onPrimary (Single closes, Multi consumes + repaints).
ui/picker/PickerListRenderer.kt — renderer skeletonAbstract ListCellRenderer<T> base: PickerRow wrap + optional top GroupHeaderSeparator
(driven by sectionTitle) + [check icon | content | trailing] layout with the shared
transparent row + UiStyle.Gap.md/lg/md/pad insets. Provides:
check icon column (AllIcons.Actions.Checked / EmptyIcon), set from checked(row).content: JComponent slot and optional trailing: JComponent slot.top/sep from ModelPickerRenderer).trailingClickZone(list, bounds, point, width) generalizing
ModelPickerRenderer.isFavoriteClick (keep FAVORITE_CLICK_AREA_WIDTH behavior).ui/picker/PickerListRenderer.kt: base renderer skeleton with check column,
PickerRow wrap, top section separator, content/trailing slots, transparent row + insets,
and the trailingClickZone helper (moved/generalized from ModelPickerRenderer.isFavoriteClick
/ favoriteInset).ui/picker/PickerPopup.kt: generic popup per the design above, including the
moved popupBackground helper and the sizing helpers (parameterized).ModelPickerRenderer to extend PickerListRenderer: content = title
(SimpleColoredComponent) + warn + free/BYOK badges + provider label; trailing = favorite
star; check via checked. Keep existing internal test accessors (starIcon,
badgeVisible, badgeText, byokVisible, warningVisible, warningTooltip) and the
DATA_COLLECTED/checked/empty companion members so ModelPickerTest compiles unchanged.ModelPicker.showPopup to build a PickerPopup<ModelPickerRow> configured
as: Single mode; rows = { q -> modelPickerRows(items, favorites(), q, allowEmpty, emptyText, includeSmall) };
checked = { it.key == selected?.key }; sectionTitle = ::modelPickerSectionTitle;
onPrimary = { row -> row.item?.let(::activate) ?: clear() }; trailing = favorite hit-zone +
onFavoriteToggle; search = true; details = ModelDetailsPanel with expandStateKey = MODEL_PICKER_EXPANDED_KEY; placement from placement; sizing constants = current values.
Keep ModelPicker's public API (setItems, select, clearSelection, open, callbacks,
Placement, Item) and test hooks (selectedForTest, selectionKeyForTest,
expandedForTest) unchanged so PromptPanel, settings/models, and settings/agents
consumers are unaffected.PickerListRenderer<String> in
settings/providers/, or a shared simple text renderer): content = a JBLabel showing the
model id; no trailing; check via checked.CustomProviderDialog.showModelPopup(ids) to build a PickerPopup<String>:
Multi mode; rows = { _ -> ids } (raw model ids, no select-all row); checked = { it in modelIds().toSet() };
onPrimary = { toggleModel(it) }; sectionTitle = { _, _ -> null }; search = false;
toolbar = listOf(selectAllButton, deselectAllButton); details = null; minWidth = 320;
anchor = pick, placement UNDERNEATH.CustomProviderDialog
operating on the models text field: toggleModel(id), selectAllModels(ids),
clearModels() (reuse existing modelIds() / setModelIds()). Wire the two toolbar buttons
to selectAllModels(ids) and clearModels().CustomModelRow, customModelRows, CustomModelRowRenderer,
CUSTOM_MODEL_POPUP_MIN_WIDTH/CUSTOM_MODEL_POPUP_MAX_ROWS (fold into base config), and the
now-duplicated inline popupBackground in ProvidersSettingsUi.kt.KiloBundle.properties (e.g.
settings.providers.customModelsDeselectAll=Deselect All); reuse existing
settings.providers.customModelsSelectAll for "Select All".ModelPickerTest.kt (row builder + renderer + ModelSearch +
favorite hit-zone). Because ModelPickerRenderer keeps its accessors and companion members and
modelPickerRows/ModelPickerRow are untouched, these should compile and pass as-is. Verify
the favorite hit-zone tests still reference a working symbol (either keep
ModelPickerRenderer.isFavoriteClick delegating to PickerListRenderer.trailingClickZone, or
update the 3 call sites in ModelPickerTest.kt to the new helper).ProvidersSettingsUiTest.kt:
test custom model rows start with select all (there is no select-all row anymore)
with tests for the extracted helpers: toggleModel adds/removes an id in the text field;
selectAllModels(ids) sets all; clearModels() empties it.PickerListRenderer/PickerPopup unit test where feasible without a live
popup — e.g. trailingClickZone geometry (mirroring the current isFavoriteClick tests) and
the provider text renderer check-state. Avoid asserting live-popup internals; follow the
existing pattern of testing renderers/row-builders/pure helpers directly (per package AGENTS
"test the real implementation, no mocks").From packages/kilo-jetbrains/:
./gradlew typecheck./gradlew test (iterate with --tests ai.kilocode.client.session.ui.model.ModelPickerTest
and --tests ai.kilocode.client.settings.providers.ProvidersSettingsUiTest)../gradlew runIde:
ModelPicker's public API and test hooks must stay stable — several settings screens depend on
it. Only the popup internals move.Disposable; the base must register it with the popup exactly as
today to avoid leaks.ModePicker, ReasoningPicker, SessionAccountOverlay) — not part
of this consolidation.