Kilo JetBrains 前端重构:用共享 PickerPopup 基类收敛两个模型选择弹窗
2026/9/10 22:13:06 网站建设 项目流程

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 基建代码:

  1. Prompt 模型选择器——位于 ModelPicker.kt 的showPopup:单选提交、带搜索框、支持展开/收起详情面板、带分区(Favorites / Recommended / 各 Provider 分组)、每行右侧有收藏星标切换。
  2. 自定义 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>PickerRowModelSearch保持原位被复用。

PickerPopup 基类:配置驱动的通用弹窗

构造参数总览

PickerPopup<T>的全部能力都由构造参数(或成员变量)暴露,默认值贴合原ModelPicker的既有行为。下表结合 PickerPopup.kt 的实际实现整理:

参数类型 / 默认值语义
anchorJComponent弹窗锚点组件。Prompt 用按钮自身(ABOVE/BELOW),Provider 用pick按钮(UNDERNEATH
placementPlacementABOVE/BELOW/UNDERNEATHABOVEPopupShowOptions.aboveComponent(anchor)BELOW/UNDERNEATHshowUnderneathOf(anchor)
rows(query: String) -> List<T>搜索文本变化时重建行数据;Provider 原计划传入忽略query的函数
modelCollectionListModel<T>列表数据模型,由基类与调用方共享,供refresh()重建
rendererPickerListRenderer<T>行渲染器
key(T) -> Any?行稳定标识,用于refresh(prefer=...)时恢复高亮
modeModeSingle/Multi单选提交即关闭;多选切换后保持打开(同时派生autoClose
autoCloseBoolean(默认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触发
searchBoolean,默认false是否在头部 CENTER 显示SearchTextField
toolbarList<JComponent>,默认空头部 WEST 的额外按钮;Provider 传入 Select All / Deselect All
details/onPreview/expandStateKeyJComponent?/(T?) -> Unit/String?非空时显示展开HoverIcon与 EAST 详情面板,用PropertiesComponent持久化展开状态,details is Disposable时通过Disposer.register(popup, details)防泄漏
minWidth/maxWidth/maxVisibleRows/emptyListHeight420/760/10/120尺寸参数。Provider 传minWidth = 320
emptyText字符串,默认model.picker.no.matches空列表占位文案

弹窗骨架与公共标志

基类在show()中统一完成:安装搜索、键盘、鼠标与展开监听,ListUtil.installAutoSelectOnMouseMove(list)ScrollingUtil.installActions(list)挂载自动选中与滚动,然后组装contentBorderLayout: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)重建数据、replaceAllCollectionListModel,然后按prefer键(默认当前选中行)恢复高亮,找不到时回退到第一行——源码注释特意对比了ActiveListView:列表选择器必须始终给出一个可接受候选项,因此不会出现「记忆行消失后选中态为空」的情况。收藏切换后 Prompt 侧正是通过popup?.refresh(prefer = item.key); popup?.repaint()完成即时刷新。

键盘与鼠标交互约定

键盘绑定同时注册在列表(以及存在时的搜索编辑器)上,语义按模式区分:

按键行为
UP/DOWN移动选中(仅搜索框需要注册;列表本身由ScrollingUtil处理)
ENTER对选中行执行 primary(单选:回调后closeOk(null);多选:刷新并保持打开)
ESCpopup.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>基类,构造时接收modelchecked: (T) -> BooleansectionTitle、受保护槽位content: JComponent与可选trailing: JComponent。它负责统一行外观:

  • 勾选图标列AllIcons.Actions.Checked/ 等尺寸EmptyIcon,由checked(row)决定;
  • 分区分隔条:复用GroupHeaderSeparator作为顶部面板(top/sep接线沿袭自原ModelPickerRenderer),sep.setHideLine(index == 0)隐藏首行分隔线;
  • 行布局PickerRowSelectablePanel子类)包裹[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.SingleonPrimary内部按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 = truedetails = ModelDetailsPanelexpandStateKey = 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_COLLECTEDcheckedempty伴生成员,以及starIcon()badgeVisible()badgeText()byokVisible()warningVisible()warningTooltip()内部访问器)被完整保留,因此 ModelPickerTest.kt 无需改动即可编译通过。

同时ModelPicker的对外 API(setItemsselectclearSelectionopencycle、回调、PlacementItem)与测试钩子(selectedForTestselectionKeyForTestexpandedForTest)保持稳定,PromptPanelsettings/modelssettings/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 )

要点与计划的一致性/演进点:

  • CustomModelRowcustomModelRows的「全选行」概念被删除,PickerRow数据就是纯String模型 id 列表;
  • CustomModelRendererPickerListRenderer<String>的一个小型子类,content就是一个显示模型 id 的JBLabelchecked = { it in selected() }sectionTitle = { _, _ -> null },无 trailing;
  • 头部工具栏由调用方传入两个ActionLinkcustomModelsSelectAll/customModelsUnselectAll)并夹一个竖向JSeparator
  • 计划原把 provider 弹窗的search定为关闭(并列为 Out of scope),但落地实现将其开启并让customModelRows按子串过滤——从最终代码看,这成为计划之外的一项增强,也证明了基类「搜索能力开箱即用」的设计价值;
  • 尺寸固定 320 宽、10 行可见,anchor 为pick按钮、UNDERNEATH展示。

选择状态归属与可测试的纯函数

多选状态始终以对话框的models文本框为唯一事实来源,基类不感知任何状态。为便于单元测试,计划把三类变更操作提取为CustomProviderDialog上的可见辅助方法,复用既有的modelIds()/setModelIds()setModelIds内部draft = nulldistinct().joinToString(", ")写回文本框):

  • toggleModel(id, order):把 id 加入或移出集合,并按order保持原有排列顺序写回;
  • selectAllModels(ids):一次性写入全部 id;
  • clearModels():清空。

两个工具栏按钮分别绑定selectAllModels(ids)clearModels(),点击后调用picker.repaint() + syncActions()同步勾选态与 OK 按钮可用性。由于这些方法只操作文本框字符串,无需真实弹窗即可测试。

测试策略

计划的测试策略遵循「直接测试真实实现、不打桩」的包内 AGENTS 约定,按三类处理:

  1. 保持原样通过:ModelPickerTest.kt 覆盖行构建器(modelPickerRows)、分区顺序、ModelSearch缩写过滤、modelCycle循环顺序、渲染器徽标/警告/收藏星标、以及收藏点击区的 LTR/RTL 几何(ModelPickerRenderer.isFavoriteClick委托trailingClickZone后符号依然有效)。
  2. 更新ProvidersSettingsUiTest.kt:原「列表以 select all 行开头」的用例随行删除而废弃,替换为对toggleModel(添加/移除 id)、selectAllModels(写全量)、clearModels(清空)的断言;新增/编辑/删除 Provider 与对话框关闭用例保持不变。
  3. 新增:对纯几何/纯渲染的单元测试——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 前端而言,这套模式既消除了两处弹窗的重复实现,又为后续其它列表选择弹窗(如计划中明确暂不纳入的ModePickerReasoningPickerSessionAccountOverlay)提供了即插即用的底座——新弹窗只需提供数据、渲染器和一行配置即可获得与模型选择器一致的交互与观感。

【免费下载链接】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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询