Kilo JetBrains 前端重构:用共享 PickerPopup 基类收敛两个模型选择弹窗
【免费下载链接】kilocodeKilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/ki/kilocode
本篇技术指南以仓库内 consolidate-jetbrains-model-pickers 计划文档 为骨架,讲解 Kilo JetBrains 插件前端如何把两个高度重复的列表选择弹窗——会话界面的「Prompt 模型选择器」与设置页自定义 Provider 的「模型多选弹窗」——收敛为一个配置驱动的泛型基类PickerPopup<T>,并配套抽取渲染基类PickerListRenderer<T>。读者读完后,将掌握这套「薄配置 + 泛型弹窗 + 可扩展渲染器」的组件化重构思路,理解其配置参数、键盘/鼠标交互约定、选择状态归属与测试策略,并能对照当前仓库源码(该计划已在packages/kilo-jetbrains/frontend/落地)直接复用或迁移这套模式。
背景:两处重复的列表弹窗实现
在重构之前,JetBrains 前端存在两个功能相近但实现各自为政的列表弹窗,重复了弹窗构建、背景色、头部、命中测试、键盘绑定、滚动与尺寸计算等一大段 UI 基建代码:
- Prompt 模型选择器——位于 ModelPicker.kt 的
showPopup:单选提交、带搜索框、支持展开/收起详情面板、带分区(Favorites / Recommended / 各 Provider 分组)、每行右侧有收藏星标切换。 - 自定义 Provider 模型选择器——位于 ProvidersSettingsUi.kt 的
CustomProviderDialog.showModelPopup:多选切换、选中的模型写入对话框的models文本框。
两个弹窗的核心差异点包括:单选 vs 多选、有无搜索框、有无详情/展开面板、有无每行尾部的收藏点击区、有无头部工具栏按钮("Select All" / "Deselect All")。重构目标就是把差异参数化,让两个调用方都变成一份「薄配置」。
计划明确限定了改造范围:全部工作集中在packages/kilo-jetbrains/frontend/,不涉及任何后端 / RPC / DTO 改动,且被改造的文件均为 Kilo 自有代码(无kilocode_change上游标记)。从当前仓库源码看,该计划已完整落地:新增了ai/kilocode/client/ui/picker/包,包含 PickerPopup.kt 与 PickerListRenderer.kt。
总体思路与已定决策
计划的几个关键决策决定了整个架构形态:
- 抽取泛型基类
PickerPopup<T>:两个弹窗退化为薄配置,所有公共行为收进基类。 - 选择状态由调用方持有:基类本身是「无选择状态」的——它只通过
checked(row)渲染勾选图标,通过onPrimary(row)响应用户激活。因此 Prompt 侧继续用 favorites 回调维护收藏,Provider 侧继续把models文本框当作唯一事实来源。 - Select All / Deselect All 变为头部按钮:由调用方提供两个按钮组件放入头部工具栏,删除原先列表内的
CustomModelRow.selectAll特殊行。 - 渲染器同步抽取基类:
ModelPickerRenderer与 Provider 渲染器共同继承PickerListRenderer;收藏星标的点击热区被泛化为通用的「尾部点击区」辅助函数。 - 新增包
ai/kilocode/client/ui/picker/:承载PickerPopup<T>与PickerListRenderer<T>;PickerRow与ModelSearch保持原位被复用。
PickerPopup 基类:配置驱动的通用弹窗
构造参数总览
PickerPopup<T>的全部能力都由构造参数(或成员变量)暴露,默认值贴合原ModelPicker的既有行为。下表结合 PickerPopup.kt 的实际实现整理:
| 参数 | 类型 / 默认值 | 语义 |
|---|---|---|
anchor | JComponent | 弹窗锚点组件。Prompt 用按钮自身(ABOVE/BELOW),Provider 用pick按钮(UNDERNEATH) |
placement | Placement(ABOVE/BELOW/UNDERNEATH) | ABOVE走PopupShowOptions.aboveComponent(anchor),BELOW/UNDERNEATH走showUnderneathOf(anchor) |
rows | (query: String) -> List<T> | 搜索文本变化时重建行数据;Provider 原计划传入忽略query的函数 |
model | CollectionListModel<T> | 列表数据模型,由基类与调用方共享,供refresh()重建 |
renderer | PickerListRenderer<T> | 行渲染器 |
key | (T) -> Any? | 行稳定标识,用于refresh(prefer=...)时恢复高亮 |
mode | Mode(Single/Multi) | 单选提交即关闭;多选切换后保持打开(同时派生autoClose) |
autoClose | Boolean(默认mode == Single) | 主点击后是否关闭弹窗;false时底部会出现 Close 按钮 |
onPrimary | (T) -> Unit | 主操作回调。Prompt 在回调内部依据row.item == null决定激活或清除;Provider 切换文本框中的模型成员 |
sectionTitle | (List<T>, Int) -> String?,默认{ _, _ -> null } | 分区标题;Prompt 传modelPickerSectionTitle,Provider 传 null |
trailingHit/onTrailing | ((JList<*>, Rectangle, Point) -> Boolean)?/((T) -> Unit)? | 可选的尾部点击区(如收藏星标热区)。键盘上,Single 模式按Shift+SPACE触发 |
search | Boolean,默认false | 是否在头部 CENTER 显示SearchTextField |
toolbar | List<JComponent>,默认空 | 头部 WEST 的额外按钮;Provider 传入 Select All / Deselect All |
details/onPreview/expandStateKey | JComponent?/(T?) -> Unit/String? | 非空时显示展开HoverIcon与 EAST 详情面板,用PropertiesComponent持久化展开状态,details is Disposable时通过Disposer.register(popup, details)防泄漏 |
minWidth/maxWidth/maxVisibleRows/emptyListHeight | 420/760/10/120 | 尺寸参数。Provider 传minWidth = 320 |
emptyText | 字符串,默认model.picker.no.matches | 空列表占位文案 |
弹窗骨架与公共标志
基类在show()中统一完成:安装搜索、键盘、鼠标与展开监听,ListUtil.installAutoSelectOnMouseMove(list)与ScrollingUtil.installActions(list)挂载自动选中与滚动,然后组装content(BorderLayout:NORTH 头部、CENTER 滚动列表、SOUTH 可选 footer、EAST 可选详情),最后通过createComponentPopupBuilder构建并展示:
.createComponentPopupBuilder(content, field?.textEditor ?: list) .setRequestFocus(true) .setFocusable(true) .setCancelOnClickOutside(true) .setCancelKeyEnabled(true) .setCancelOnWindowDeactivation(true) .setLocateWithinScreenBounds(true) .setResizable(false) .setMovable(false)即:请求焦点、可聚焦、点击外部取消、ESC 可取消、窗口失活取消、限制在屏幕范围内、不可缩放、不可拖动。同时popupBackground辅助函数被移入ui/picker包内统一维护:
internal val popupBackground: Color get() = if (NewUI.isEnabled()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground()列表、滚动视口、头部与详情面板全部使用该背景,并调用PopupUtil.applyNewUIBackground(list)与AbstractPopup.customizeSearchFieldLook(it, true)保证新旧 UI 外观一致。
头部布局
头部是一个BorderLayout面板:WEST 放toolbar按钮(水平 Stack 排列),CENTER 放搜索框(search=false时为空),EAST 放展开HoverIcon(仅当 details 存在时),从而保持了AbstractPopup.customizeSearchFieldLook与背景接线。展开图标来自/icons/expand.svg与/icons/collapse.svg,随状态切换并同步 tooltip 与 accessible name。
refresh()是基类对外暴露的关键方法:它依据rows(field?.text)重建数据、replaceAll进CollectionListModel,然后按prefer键(默认当前选中行)恢复高亮,找不到时回退到第一行——源码注释特意对比了ActiveListView:列表选择器必须始终给出一个可接受候选项,因此不会出现「记忆行消失后选中态为空」的情况。收藏切换后 Prompt 侧正是通过popup?.refresh(prefer = item.key); popup?.repaint()完成即时刷新。
键盘与鼠标交互约定
键盘绑定同时注册在列表(以及存在时的搜索编辑器)上,语义按模式区分:
| 按键 | 行为 |
|---|---|
UP/DOWN | 移动选中(仅搜索框需要注册;列表本身由ScrollingUtil处理) |
ENTER | 对选中行执行 primary(单选:回调后closeOk(null);多选:刷新并保持打开) |
ESC | popup.cancel() |
Shift+SPACE | 仅 Single 且存在 trailing 时:触发尾部操作(如收藏切换) |
SPACE | 仅 Multi:对选中行执行 primary(即切换选中) |
鼠标统一走mouseReleased+UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true):先用locationToIndex解析行索引,再用getCellBounds+contains校验点击落在行内;若配置了trailingHit且命中则执行onTrailing并消费事件,否则执行onPrimary(Single 关闭、Multi 刷新重绘)。
尺寸计算
原先ModelPicker里的computeInitialPopupSize/computeListPreferredWidth/computeListPreferredHeight三件套被整体移入基类并参数化:
- 宽度取「列表渲染出的最大行宽」与头部/底部
preferredSize.width中的较大者,再钳制在JBUI.scale(minWidth)..JBUI.scale(maxWidth)区间内;渲染器不可用时回退minWidth。 - 高度对前
maxVisibleRows行逐行调用渲染器求和(行数超过上限时追加滚动条宽度),空列表回退emptyListHeight。 - 展开详情时,在总宽上追加一份「详情宽度」(等于当前弹窗宽度),并让详情面板高度与滚动区对齐。
PickerListRenderer:可扩展的行渲染骨架
PickerListRenderer.kt 是一个抽象ListCellRenderer<T>基类,构造时接收model、checked: (T) -> Boolean、sectionTitle、受保护槽位content: JComponent与可选trailing: JComponent。它负责统一行外观:
- 勾选图标列:
AllIcons.Actions.Checked/ 等尺寸EmptyIcon,由checked(row)决定; - 分区分隔条:复用
GroupHeaderSeparator作为顶部面板(top/sep接线沿袭自原ModelPickerRenderer),sep.setHideLine(index == 0)隐藏首行分隔线; - 行布局:
PickerRow(SelectablePanel子类)包裹[check | content],可选 trailing 传入wrap.setContent(row, trailing);行内边距为UiStyle.Gap.md()/lg()/md()/pad(),透明背景统一交给UiStyle.Components.transparent。
子类只需实现update(...)完成自身内容填充。伴生对象还提供了泛化的尾部点击区工具:
fun trailingClickZone(list: JList<*>, bounds: Rectangle, point: Point, width: Int): Boolean { val size = JBUI.scale(width) val inset = trailingInset(list) if (list.componentOrientation.isLeftToRight) { val right = bounds.x + bounds.width - inset return point.x in (right - size)..right } val left = bounds.x + inset return point.x in left..(left + size) }trailingInset会叠加Popup.Selection.LEFT_RIGHT_INSET与 New UI 的innerInsets,并感知 RTL 方向——这正是原ModelPickerRenderer.isFavoriteClick逻辑(FAVORITE_CLICK_AREA_WIDTH = 32)的泛化,点击区域几何行为与既有测试保持一致。
落地一:Prompt 模型选择器(Single 模式)
重构后的ModelPicker.showPopup()在 ModelPicker.kt 中变成一份PickerPopup<ModelPickerRow>配置:
mode = Mode.Single,onPrimary内部按row.item == null决定clear()还是activate(item)(对应空行「Not set」与正常模型);rows = { q -> modelPickerRows(items, favorites(), q, allowEmpty, emptyText, includeSmall) },搜索时经ModelSearch的缩写/分词匹配过滤;checked = { it.key == selected?.key }(单选以当前激活模型为勾选依据);sectionTitle = ::modelPickerSectionTitle(Favorites / Recommended / Provider 分组,见 ModelPickerRows.kt);- trailing 复用
ModelPickerRenderer.isFavoriteClick(内部委托给PickerListRenderer.trailingClickZone),点击触发onFavoriteToggle后经refreshFavorite回调刷新弹窗; search = true,details = ModelDetailsPanel(expandStateKey = MODEL_PICKER_EXPANDED_KEY,即kilo.model.picker.expanded,展开状态持久化到PropertiesComponent);- 尺寸沿用原常量 420 / 760 / 10 / 120。
对应地,ModelPickerRenderer.kt 改为继承PickerListRenderer<ModelPickerRow>:content是「标题SimpleColoredComponent+ 数据收集警告warn+ Free/BYOK 徽标 + 收藏 Provider 标签」组合面板,trailing是收藏星标。它的公开测试钩子(DATA_COLLECTED、checked、empty伴生成员,以及starIcon()、badgeVisible()、badgeText()、byokVisible()、warningVisible()、warningTooltip()内部访问器)被完整保留,因此 ModelPickerTest.kt 无需改动即可编译通过。
同时ModelPicker的对外 API(setItems、select、clearSelection、open、cycle、回调、Placement、Item)与测试钩子(selectedForTest、selectionKeyForTest、expandedForTest)保持稳定,PromptPanel、settings/models、settings/agents等消费方完全不受影响——这是计划明确划出的兼容性红线。
落地二:自定义 Provider 模型选择器(Multi 模式)
CustomProviderDialog.showModelPopup(ids)在 ProvidersSettingsUi.kt 中同样收敛为PickerPopup<String>配置:
picker = PickerPopup( anchor = pick, placement = PickerPopup.Placement.UNDERNEATH, rows = { query -> customModelRows(ids, query) }, model = data, renderer = CustomModelRenderer(data) { modelIds().toSet() }, mode = PickerPopup.Mode.Multi, onPrimary = { toggleModel(it, ids); syncActions() }, search = true, toolbar = listOf(select, JSeparator(SwingConstants.VERTICAL), clear), minWidth = CUSTOM_MODEL_POPUP_WIDTH, // 320 maxWidth = CUSTOM_MODEL_POPUP_WIDTH, maxVisibleRows = CUSTOM_MODEL_POPUP_MAX_ROWS, // 10 )要点与计划的一致性/演进点:
CustomModelRow、customModelRows的「全选行」概念被删除,PickerRow数据就是纯String模型 id 列表;CustomModelRenderer是PickerListRenderer<String>的一个小型子类,content就是一个显示模型 id 的JBLabel,checked = { it in selected() },sectionTitle = { _, _ -> null },无 trailing;- 头部工具栏由调用方传入两个
ActionLink(customModelsSelectAll/customModelsUnselectAll)并夹一个竖向JSeparator; - 计划原把 provider 弹窗的
search定为关闭(并列为 Out of scope),但落地实现将其开启并让customModelRows按子串过滤——从最终代码看,这成为计划之外的一项增强,也证明了基类「搜索能力开箱即用」的设计价值; - 尺寸固定 320 宽、10 行可见,anchor 为
pick按钮、UNDERNEATH展示。
选择状态归属与可测试的纯函数
多选状态始终以对话框的models文本框为唯一事实来源,基类不感知任何状态。为便于单元测试,计划把三类变更操作提取为CustomProviderDialog上的可见辅助方法,复用既有的modelIds()/setModelIds()(setModelIds内部draft = null并distinct().joinToString(", ")写回文本框):
toggleModel(id, order):把 id 加入或移出集合,并按order保持原有排列顺序写回;selectAllModels(ids):一次性写入全部 id;clearModels():清空。
两个工具栏按钮分别绑定selectAllModels(ids)与clearModels(),点击后调用picker.repaint() + syncActions()同步勾选态与 OK 按钮可用性。由于这些方法只操作文本框字符串,无需真实弹窗即可测试。
测试策略
计划的测试策略遵循「直接测试真实实现、不打桩」的包内 AGENTS 约定,按三类处理:
- 保持原样通过:ModelPickerTest.kt 覆盖行构建器(
modelPickerRows)、分区顺序、ModelSearch缩写过滤、modelCycle循环顺序、渲染器徽标/警告/收藏星标、以及收藏点击区的 LTR/RTL 几何(ModelPickerRenderer.isFavoriteClick委托trailingClickZone后符号依然有效)。 - 更新ProvidersSettingsUiTest.kt:原「列表以 select all 行开头」的用例随行删除而废弃,替换为对
toggleModel(添加/移除 id)、selectAllModels(写全量)、clearModels(清空)的断言;新增/编辑/删除 Provider 与对话框关闭用例保持不变。 - 新增:对纯几何/纯渲染的单元测试——
trailingClickZone的点击区计算(镜像原isFavoriteClick测试)与 Provider 文本渲染器的勾选态;刻意不触碰活体弹窗内部状态。
验证步骤
计划给出的验证入口(在packages/kilo-jetbrains/目录下):
./gradlew typecheck全量类型检查;./gradlew test,可配合--tests ai.kilocode.client.session.ui.model.ModelPickerTest与--tests ai.kilocode.client.settings.providers.ProvidersSettingsUiTest迭代定位;./gradlew runIde手工冒烟:Prompt 侧验证搜索、方向键/回车选择、收藏星标、详情展开/收起与 placement;自定义 Provider 侧验证「新增 Provider → 拉取模型 → 弹窗多选 + 全选/取消全选 → 保存后模型列表持久化」。
风险与注意点
计划明确提示了四类风险,重构时必须守住:
- API 稳定性:
ModelPicker公开 API 与测试钩子被多个设置页依赖,只有弹窗内部实现可以搬移; - 资源泄漏:
details详情面板是Disposable,基类必须保持Disposer.register(popup, details)的既有注册时机; - 尺寸差异:Prompt 侧是 420/760 动态宽度 + 详情扩展,Provider 侧固定 320——必须参数化而非硬编码,保持视觉等价;
- 交互语义差异:单选「提交即关闭」、多选「切换保持打开」、以及是否存在尾部操作,都应在基类的
Mode与参数上编码,而不是在调用方打补丁式特判。
小结
这次收敛的本质,是把「弹窗基建」与「业务选择逻辑」彻底解耦:PickerPopup<T>负责弹窗生命周期、键盘/鼠标、搜索、分区、详情展开与尺寸计算;PickerListRenderer<T>负责行骨架、勾选列与尾部点击区;两个调用方各持一份声明式配置和属于自己的状态(单选激活项 /models文本框)。对 Kilo JetBrains 前端而言,这套模式既消除了两处弹窗的重复实现,又为后续其它列表选择弹窗(如计划中明确暂不纳入的ModePicker、ReasoningPicker、SessionAccountOverlay)提供了即插即用的底座——新弹窗只需提供数据、渲染器和一行配置即可获得与模型选择器一致的交互与观感。
【免费下载链接】kilocodeKilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/ki/kilocode
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考