@andares/pi-toolkits 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -161,14 +161,19 @@ pi -e ./src/index.ts
161
161
 
162
162
  **范围强约束** —— 评审仅限**当前本轮修改内容**,避免扩大范围;本轮改动范围内确认的问题才直接修复,范围外的历史遗留/无关问题只记录提示、不动手改。
163
163
 
164
- **评审模型配置**(`<agent-dir>/pi-toolkits-review.json`,默认 `~/.pi/agent/`):
164
+ **评审模型选择**(每次 `/third-review` 都会弹出选择界面):
165
+
166
+ - `/third-review` → 弹出 **`/model` 同款选择器**(限高滚动 + 模糊搜索):**默认选中当前配置的模型**(✓ 标记),直接**回车**即用它开始评审
167
+ - **换选其他模型** → 立即写回配置(**落盘 + 内存**),**下次默认使用该模型**
168
+ - **取消**(Esc)→ 不执行评审,配置不变
169
+ - 配置持久化于 `<agent-dir>/pi-toolkits-review.json`(默认 `~/.pi/agent/`):
165
170
 
166
171
  ```json
167
172
  { "model": "anthropic/claude-sonnet-4" }
168
173
  ```
169
174
 
170
- - `/review-model` —— 查看/更换评审模型:弹出模型列表(标题显示当前配置),选中即写入;取消则不修改配置
171
- - **未配置 / 配置的模型已不可用**(如在 pi 的 models.json 里删掉了该模型)→ 提示「评审模型不存在」,弹出**可用模型选择列表**(与 `/model` 同源);选中后**写回配置并立即执行**
175
+ - `/review-model` —— 单独的配置入口:弹出同一列表(不改会话模型,只更新默认配置)
176
+ - **未配置 / 配置的模型已不可用**(如在 pi 的 models.json 里删掉了该模型)→ 提示后照常弹出列表(此时无默认项)
172
177
  - 任务仍在进行中(agent 未空闲)时触发 → 提示等待,不执行;同一时间只允许一个评审
173
178
  - footer 出现灰色 `review` 状态 = 已配置评审模型
174
179
 
@@ -201,7 +206,7 @@ pi 的 `/compact` 与 auto-compact 默认**用当前会话模型**做上下文
201
206
  { "model": "google/gemini-2.5-flash" }
202
207
  ```
203
208
 
204
- - `/compact-model` —— 查看/更换/清除:弹出可用模型列表(标题显示当前配置,首项「✕ 清除配置」),选中即写入;取消则不修改
209
+ - `/compact-model` —— 查看/更换:`/model` 同款选择器(限高滚动 + 模糊搜索),**默认选中当前配置的模型**,回车即确认;换选即写入;取消则不变(清除配置:编辑 `pi-toolkits-compact.json` 把 `model` 置为 `null`)
205
210
  - 未配置 = 功能未启用,全部走 pi 默认,零开销;footer 出现灰色 `compact` 状态 = 已配置
206
211
 
207
212
  **无损回落**(本功能永远不会把压缩「做挂」):
@@ -217,45 +222,6 @@ pi 的 `/compact` 与 auto-compact 默认**用当前会话模型**做上下文
217
222
 
218
223
  ---
219
224
 
220
- **pnpm is the only supported toolchain** — never use `npm install` / `npm publish` in this repo.
221
-
222
- ```bash
223
- pnpm install # install deps
224
- pnpm typecheck # tsc --noEmit
225
- pnpm test # vitest (all feature unit tests)
226
- ```
227
-
228
- ### 项目结构 Project structure
229
-
230
- ```text
231
- src/
232
- ├── index.ts # entry point: aggregates feature modules
233
- ├── lib/ # shared infrastructure (tool-set snapshot/restore)
234
- └── features/ # feature modules — one directory per feature
235
- ├── ask/ # ask mode: command + state machine, bash sandbox,
236
- │ # system prompt banner, tests
237
- ├── favorites/ # model favorites: selector + cycle patches,
238
- │ # persisted store, tests
239
- ├── review/ # third-party review: input/command triggers,
240
- │ # review-model config store, prompt, tests
241
- └── stash/ # prompt stash: hotkey state machine + tests
242
- ```
243
-
244
- **新增功能 · adding a feature**:create `src/features/<name>/` exporting `registerXxx(pi)` and call it from `src/index.ts`. Existing modules stay untouched. Each feature ships its own `*.test.ts` (vitest) — keep them deterministic (inject clocks/state, no real agent-dir writes).
245
-
246
- **发布 · publishing**(pnpm-only,one command):
247
-
248
- ```bash
249
- pnpm release patch # 0.1.2 → 0.1.3
250
- pnpm release minor # 0.1.2 → 0.2.0 (patch zeroed)
251
- pnpm release major # 0.1.2 → 1.0.0 (minor + patch zeroed)
252
- pnpm release patch --dry-run # preview without changing anything
253
- ```
254
-
255
- `release` 要求且仅要求 `major | minor | patch` 之一;上级递增清零下级。执行链:校验 → `typecheck + test` 门禁 → 改版本 → git commit + `vX.Y.Z` tag → `pnpm publish`(`prepublishOnly` 二次门禁)。
256
-
257
- ---
258
-
259
225
  ## 🛠️ 开发 Development
260
226
 
261
227
  <a name="development"></a>
@@ -299,7 +265,7 @@ pnpm release major # 0.1.2 → 1.0.0 (minor + patch zeroed)
299
265
  pnpm release patch --dry-run # preview without changing anything
300
266
  ```
301
267
 
302
- `release` 要求且仅要求 `major | minor | patch` 之一;上级递增清零下级。执行链:校验 → `typecheck + test` 门禁 → 改版本 → git commit + `vX.Y.Z` tag → `pnpm publish`(`prepublishOnly` 二次门禁)。
268
+ `release` 要求且仅要求 `major | minor | patch` 之一;上级递增清零下级。执行链:**工作区必须干净(有未提交变更直接中止,保证 tag 内容 == 发布内容)** → `typecheck + test` 门禁 → 改版本 → git commit + `vX.Y.Z` tag(自检 tag==HEAD 且工作区干净) → `pnpm publish`(`prepublishOnly` 二次门禁) → 只推当前分支 + 当前 tag 并校验远端哈希一致(远端 tag 不一致则跳过 Release 创建)。
303
269
 
304
270
  ---
305
271
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Integrated toolkit extension for pi — ask mode (read-only Q&A), model favorites, prompt stash, third-party code review, and a dedicated compaction model",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -133,11 +133,14 @@ interface CtxOverrides {
133
133
  options: { maxTokens?: number; signal?: AbortSignal },
134
134
  ) => Promise<AssistantMessageLike>;
135
135
  hasUI?: boolean;
136
+ mode?: string;
137
+ customResult?: AvailableModel | undefined;
136
138
  selectResult?: string | undefined;
137
139
  }
138
140
 
139
141
  function fakeCtx(overrides: CtxOverrides = {}) {
140
142
  const notify = vi.fn();
143
+ const custom = vi.fn(async () => overrides.customResult);
141
144
  const select = vi.fn(async () => overrides.selectResult);
142
145
  const setStatus = vi.fn();
143
146
  const complete =
@@ -146,16 +149,16 @@ function fakeCtx(overrides: CtxOverrides = {}) {
146
149
  CtxOverrides["complete"]
147
150
  >);
148
151
  const ctx = {
149
- mode: "tui",
152
+ mode: overrides.mode ?? "tui",
150
153
  hasUI: overrides.hasUI ?? true,
151
154
  isIdle: () => true,
152
155
  modelRegistry: {
153
156
  getAvailable: () => overrides.models ?? MODELS,
154
157
  complete,
155
158
  },
156
- ui: { notify, select, setStatus },
159
+ ui: { notify, custom, select, setStatus },
157
160
  } as unknown as ExtensionContext;
158
- return { ctx, notify, select, setStatus, complete };
161
+ return { ctx, notify, custom, select, setStatus, complete };
159
162
  }
160
163
 
161
164
  function fakePi() {
@@ -279,10 +282,10 @@ describe("CompactStore", () => {
279
282
 
280
283
  describe("pickCompactModel", () => {
281
284
  it("notifies and returns when there is no dialog UI", async () => {
282
- const { ctx, notify, select } = fakeCtx({ hasUI: false });
285
+ const { ctx, notify, custom } = fakeCtx({ hasUI: false });
283
286
  const model = await pickCompactModel(ctx, getCompactStore());
284
287
  expect(model).toBeUndefined();
285
- expect(select).not.toHaveBeenCalled();
288
+ expect(custom).not.toHaveBeenCalled();
286
289
  expect(notify).toHaveBeenCalledWith(
287
290
  expect.stringContaining("pi-toolkits-compact.json"),
288
291
  "error",
@@ -298,24 +301,20 @@ describe("pickCompactModel", () => {
298
301
  );
299
302
  });
300
303
 
301
- it("clears config when the clear option is chosen", async () => {
304
+ it("hints how to clear the config when cancelled", async () => {
302
305
  getCompactStore().setModel("google/gemini-2.5-flash");
303
- const { ctx, notify } = fakeCtx({
304
- selectResult: "✕ 清除配置(用 pi 默认压缩)",
305
- });
306
+ const { ctx, notify } = fakeCtx({ customResult: undefined });
306
307
  const model = await pickCompactModel(ctx, getCompactStore());
307
308
  expect(model).toBeUndefined();
308
- expect(getCompactStore().getModel()).toBeUndefined();
309
+ expect(getCompactStore().getModel()).toBe("google/gemini-2.5-flash");
309
310
  expect(notify).toHaveBeenCalledWith(
310
- expect.stringContaining("已清除"),
311
+ expect.stringContaining("置为 null"),
311
312
  "info",
312
313
  );
313
314
  });
314
315
 
315
316
  it("persists the picked model", async () => {
316
- const { ctx } = fakeCtx({
317
- selectResult: "Gemini 2.5 Flash (google/gemini-2.5-flash)",
318
- });
317
+ const { ctx } = fakeCtx({ customResult: MODELS[0] });
319
318
  const model = await pickCompactModel(ctx, getCompactStore());
320
319
  expect(modelKey(model!)).toBe("google/gemini-2.5-flash");
321
320
  expect(getCompactStore().getModel()).toBe("google/gemini-2.5-flash");
@@ -323,7 +322,7 @@ describe("pickCompactModel", () => {
323
322
 
324
323
  it("keeps config on cancel", async () => {
325
324
  getCompactStore().setModel("google/gemini-2.5-flash");
326
- const { ctx, notify } = fakeCtx({ selectResult: undefined });
325
+ const { ctx, notify } = fakeCtx({ customResult: undefined });
327
326
  await pickCompactModel(ctx, getCompactStore());
328
327
  expect(getCompactStore().getModel()).toBe("google/gemini-2.5-flash");
329
328
  expect(notify).toHaveBeenCalledWith(
@@ -331,6 +330,30 @@ describe("pickCompactModel", () => {
331
330
  "info",
332
331
  );
333
332
  });
333
+
334
+ it("warns when the configured model is unavailable", async () => {
335
+ getCompactStore().setModel("google/nonexistent");
336
+ const { ctx, notify } = fakeCtx({ customResult: MODELS[0] });
337
+ await pickCompactModel(ctx, getCompactStore());
338
+ expect(notify).toHaveBeenCalledWith(
339
+ expect.stringContaining("不存在或不可用"),
340
+ "warning",
341
+ );
342
+ expect(getCompactStore().getModel()).toBe("google/gemini-2.5-flash");
343
+ });
344
+
345
+ it("works in rpc mode via the host selector", async () => {
346
+ const { ctx, custom, select } = fakeCtx({
347
+ mode: "rpc",
348
+ selectResult: "Gemini 2.5 Flash (google/gemini-2.5-flash)",
349
+ });
350
+ const model = await pickCompactModel(ctx, getCompactStore());
351
+ // rpc cannot host custom components — the host renders select instead.
352
+ expect(custom).not.toHaveBeenCalled();
353
+ expect(select).toHaveBeenCalledTimes(1);
354
+ expect(getCompactStore().getModel()).toBe("google/gemini-2.5-flash");
355
+ expect(modelKey(model!)).toBe("google/gemini-2.5-flash");
356
+ });
334
357
  });
335
358
 
336
359
  // ─── session_before_compact handler ────────────────────────────
@@ -18,7 +18,8 @@
18
18
  * compaction worse than stock.
19
19
  *
20
20
  * Config lives in `<agent-dir>/pi-toolkits-compact.json` ("provider/id",
21
- * see store.ts). /compact-model views/changes/clears it; auto-compact paths
21
+ * see store.ts). /compact-model opens the /model-style picker (configured
22
+ * model preselected; picking persists); auto-compact paths
22
23
  * never open dialogs (handler only uses notify).
23
24
  */
24
25
  import type {
@@ -40,6 +41,7 @@ import {
40
41
  resolveConfiguredModel,
41
42
  type AvailableModel,
42
43
  } from "../../lib/model-config.js";
44
+ import { openModelPicker } from "../../lib/model-picker.js";
43
45
  import { COMPACT_STATUS_KEY } from "./constants.js";
44
46
  import {
45
47
  buildHistoryPromptText,
@@ -107,11 +109,7 @@ async function completeSummary(
107
109
  );
108
110
  }
109
111
  const text = response.content
110
- .filter(
111
- (block): block is { type: "text"; text: string } =>
112
- block.type === "text",
113
- )
114
- .map((block) => block.text)
112
+ .flatMap((block) => (block.type === "text" ? [block.text] : []))
115
113
  .join("\n");
116
114
  return { text, usage: response.usage };
117
115
  }
@@ -253,8 +251,8 @@ async function runCompactModelCompaction(
253
251
  }
254
252
 
255
253
  /**
256
- * Let the user pick a compact model (or clear the config) and persist the
257
- * choice. Mirrors review's pickReviewModel, plus a leading clear option.
254
+ * Let the user pick a compact model with the /model-style picker and persist
255
+ * the choice. Mirrors review's pickReviewModel.
258
256
  */
259
257
  export async function pickCompactModel(
260
258
  ctx: ExtensionContext,
@@ -267,45 +265,31 @@ export async function pickCompactModel(
267
265
  );
268
266
  return undefined;
269
267
  }
270
- const available = ctx.modelRegistry.getAvailable();
271
- if (available.length === 0) {
268
+ const configured = store.getModel();
269
+ const configuredModel = resolveConfiguredModel(ctx, configured);
270
+ if (!configuredModel && configured) {
272
271
  ctx.ui.notify(
273
- "compact-model: 当前没有可用模型,请先在 pi 中配置模型。",
274
- "error",
272
+ `compact-model: 配置的压缩模型 ${configured} 不存在或不可用,请重新选择。`,
273
+ "warning",
275
274
  );
276
- return undefined;
277
275
  }
278
- const configured = store.getModel();
279
- const clearLabel = "✕ 清除配置(用 pi 默认压缩)";
280
- const options = [
281
- clearLabel,
282
- ...available.map(
283
- (m: AvailableModel) => `${m.name} (${m.provider}/${m.id})`,
284
- ),
285
- ];
286
- const choice = await ctx.ui.select(
287
- configured
288
- ? `选择压缩模型 (当前: ${configured})`
289
- : "选择压缩模型 (compact-model)",
290
- options,
291
- );
292
- if (choice === undefined) {
293
- ctx.ui.notify("compact-model: 已取消,压缩模型配置未变更。", "info");
294
- return undefined;
295
- }
296
- if (choice === clearLabel) {
297
- store.setModel(undefined);
276
+ const model = await openModelPicker(ctx, configuredModel, {
277
+ selectTitle: "选择压缩模型(首项为当前默认,回车使用)",
278
+ });
279
+ if (!model) {
298
280
  ctx.ui.notify(
299
- "compact-model: 已清除配置,压缩恢复 pi 默认(当前会话模型)。",
281
+ "compact-model: 已取消,压缩模型配置未变更。清除配置请编辑 <agent-dir>/pi-toolkits-compact.json(把 model 置为 null)。",
300
282
  "info",
301
283
  );
302
284
  return undefined;
303
285
  }
304
- const model = available[options.indexOf(choice) - 1];
305
- if (!model) return undefined;
306
- store.setModel(modelKey(model));
286
+ const key = modelKey(model);
287
+ if (key !== configured) {
288
+ // Persist immediately (disk + in-memory + footer) — next compaction uses it.
289
+ store.setModel(key);
290
+ }
307
291
  ctx.ui.notify(
308
- `compact-model: 已设置压缩模型「${model.name}」,后续压缩总结将由它执行。`,
292
+ `compact-model: 压缩模型已设置为「${model.name}」,后续压缩总结将由它执行。`,
309
293
  "info",
310
294
  );
311
295
  return model;
@@ -334,10 +318,10 @@ export function registerCompact(pi: ExtensionAPI): void {
334
318
  },
335
319
  );
336
320
 
337
- // Config entry point: view, change, or clear the compact model.
321
+ // Config entry point: view and change the compact model.
338
322
  pi.registerCommand("compact-model", {
339
323
  description:
340
- "Set, change, or clear the dedicated model used for /compact summaries",
324
+ "Set or change the dedicated model used for /compact summaries",
341
325
  handler: async (_args: string, ctx: ExtensionContext) => {
342
326
  await pickCompactModel(ctx, getCompactStore());
343
327
  },
@@ -161,9 +161,11 @@ export function computeFileLists(fileOps: FileOpsLike): {
161
161
  modifiedFiles: string[];
162
162
  } {
163
163
  const modified = new Set([...fileOps.edited, ...fileOps.written]);
164
+ // pi-lens-ignore: no-sort-without-comparator, — 逐字对齐 pi 0.87.1 的裸 .sort():文件路径按码元序排序(加 locale 比较器反而会改变顺序)
164
165
  const readOnly = [...fileOps.read]
165
166
  .filter((f) => !modified.has(f))
166
167
  .sort();
168
+ // pi-lens-ignore: no-sort-without-comparator, — 同上(pi 0.87.1 computeFileLists 原文)
167
169
  const modifiedFiles = [...modified].sort();
168
170
  return { readFiles: readOnly, modifiedFiles };
169
171
  }
@@ -146,14 +146,11 @@ export function applyCyclePatch(): boolean {
146
146
  .getAvailableSnapshot()
147
147
  .map((model) => `${model.provider}\0${model.id}`),
148
148
  );
149
- candidates = this._scopedModels
150
- .filter(
151
- (sm) => availableIds.has(`${sm.model.provider}\0${sm.model.id}`),
152
- )
153
- .map((sm) => ({
154
- model: sm.model,
155
- thinkingLevel: sm.thinkingLevel,
156
- }));
149
+ candidates = this._scopedModels.flatMap((sm) =>
150
+ availableIds.has(`${sm.model.provider}\0${sm.model.id}`)
151
+ ? [{ model: sm.model, thinkingLevel: sm.thinkingLevel }]
152
+ : [],
153
+ );
157
154
  } else {
158
155
  candidates = this._modelRuntime
159
156
  .getAvailableSnapshot()
@@ -191,11 +191,8 @@ export function applyModelSelectorPatches(): boolean {
191
191
  : "";
192
192
  const cursor = isSelected ? theme.fg("accent", "→ ") : " ";
193
193
  const currentMarker = isCurrent ? theme.fg("accent", "✓ ") : " ";
194
- const modelText = isFavorite
195
- ? styleFavorite(item.id)
196
- : isSelected
197
- ? theme.fg("accent", item.id)
198
- : item.id;
194
+ const baseText = isSelected ? theme.fg("accent", item.id) : item.id;
195
+ const modelText = isFavorite ? styleFavorite(item.id) : baseText;
199
196
  const providerBadge = theme.fg("muted", `[${item.provider}]`);
200
197
  const line = `${cursor}${currentMarker}${modelText} ${providerBadge}${defaultBadge}`;
201
198
  list.addChild(new Text(line, 0, 0));
@@ -11,11 +11,15 @@
11
11
  * model as ordinary text, so an accidental send can never start a review.)
12
12
  *
13
13
  * Review-model config lives in `<agent-dir>/pi-toolkits-review.json`
14
- * ("provider/id", see store.ts). When no model is configured, or the
15
- * configured model is gone/unavailable (e.g. deleted from pi's models.json),
16
- * the user is told the review model doesn't exist and is shown the available
17
- * model list (ctx.ui.select, same getAvailable() source as /model); the pick
18
- * is written back to the config and the review runs immediately.
14
+ * ("provider/id", see store.ts) and doubles as the picker default. Every
15
+ * /third-review run opens the /model-style picker (the REAL built-in
16
+ * ModelSelectorComponent — height-limited scrolling list + fuzzy search)
17
+ * with the configured model preselected and ✓-marked: one Enter accepts it
18
+ * and starts the review. Picking a different model persists it immediately
19
+ * (disk + in-memory) as the new default for next time; cancelling aborts
20
+ * the review without changing anything. When the configured model is
21
+ * gone/unavailable (e.g. deleted from pi's models.json) the user is warned
22
+ * and the picker opens without a preselection.
19
23
  *
20
24
  * Guards: never fires while the agent is streaming (the review needs the
21
25
  * just-finished work as context) and never runs two reviews concurrently.
@@ -31,15 +35,18 @@ import {
31
35
  resolveConfiguredModel,
32
36
  type AvailableModel,
33
37
  } from "../../lib/model-config.js";
38
+ import { openModelPicker } from "../../lib/model-picker.js";
34
39
  import { REVIEW_STATUS_KEY } from "./constants.js";
35
40
  import { REVIEW_PROMPT } from "./prompt.js";
36
41
  import { getReviewStore, type ReviewStore } from "./store.js";
37
42
 
38
43
  /**
39
- * Let the user pick a review model from the available list and persist the
40
- * choice. Returns the picked model, or undefined when cancelled/unavailable.
44
+ * Let the user pick a review model with the /model-style picker and persist
45
+ * the choice. Returns the picked model, or undefined when cancelled.
41
46
  *
42
- * The picker title shows the current config so /review-model doubles as a
47
+ * The configured model is the picker's default (preselected + ✓-marked +
48
+ * first), so one Enter accepts it. Picking any other model persists it
49
+ * (disk + in-memory) as the new default. /review-model doubles as a
43
50
  * view-and-change entry point; cancelling leaves the config untouched.
44
51
  */
45
52
  export async function pickReviewModel(
@@ -55,30 +62,30 @@ export async function pickReviewModel(
55
62
  );
56
63
  return undefined;
57
64
  }
58
- const available = ctx.modelRegistry.getAvailable();
59
- if (available.length === 0) {
65
+ const configured = store.getModel();
66
+ const configuredModel = resolveConfiguredModel(ctx, configured);
67
+ if (!configuredModel && configured) {
60
68
  ctx.ui.notify(
61
- "third-review: 当前没有可用模型,请先在 pi 中配置模型。",
62
- "error",
69
+ `third-review: 配置的评审模型 ${configured} 不存在或不可用,请重新选择。`,
70
+ "warning",
63
71
  );
64
- return undefined;
65
72
  }
66
- const configured = store.getModel();
67
- // Same display source as the /model selector: name + [provider]/id.
68
- const options = available.map((m: AvailableModel) => `${m.name} (${m.provider}/${m.id})`);
69
- const choice = await ctx.ui.select(
70
- configured
71
- ? `选择评审模型 (当前: ${configured})`
72
- : "选择评审模型 (third-review)",
73
- options,
74
- );
75
- if (choice === undefined) {
73
+ const model = await openModelPicker(ctx, configuredModel, {
74
+ selectTitle: "选择评审模型(首项为当前默认,回车使用)",
75
+ });
76
+ if (!model) {
76
77
  ctx.ui.notify("third-review: 已取消,评审模型配置未变更。", "info");
77
78
  return undefined;
78
79
  }
79
- const model = available[options.indexOf(choice)];
80
- if (!model) return undefined;
81
- store.setModel(modelKey(model));
80
+ const key = modelKey(model);
81
+ if (key !== configured) {
82
+ // Persist immediately (disk + in-memory + footer) — next run defaults here.
83
+ store.setModel(key);
84
+ ctx.ui.notify(
85
+ `third-review: 评审模型已保存为「${model.name}」,下次默认使用。`,
86
+ "info",
87
+ );
88
+ }
82
89
  return model;
83
90
  }
84
91
 
@@ -109,17 +116,24 @@ export function createReviewRunner(
109
116
  running = true;
110
117
  try {
111
118
  const store = getReviewStore();
112
- const configured = store.getModel();
113
- let model = resolveConfiguredModel(ctx, configured);
114
- if (!model) {
115
- ctx.ui.notify(
116
- configured
117
- ? `third-review: 配置的评审模型 ${configured} 不存在或不可用,请重新选择。`
118
- : "third-review: 尚未配置评审模型,请选择。",
119
- "warning",
120
- );
119
+ let model: AvailableModel | undefined;
120
+ if (ctx.hasUI) {
121
+ // Always show the /model-style picker: the configured model is
122
+ // preselected (one Enter accepts it); picking another persists it.
123
+ // (The unavailable-config warning lives inside pickReviewModel.)
121
124
  model = await pickReviewModel(ctx, store);
122
125
  if (!model) return;
126
+ } else {
127
+ // No dialogs (json/print/headless): the configured-model path keeps
128
+ // working; without one, point at the config file.
129
+ model = resolveConfiguredModel(ctx, store.getModel());
130
+ if (!model) {
131
+ ctx.ui.notify(
132
+ "third-review: 当前环境不支持模型选择对话框,请编辑 <agent-dir>/pi-toolkits-review.json 手动配置。",
133
+ "error",
134
+ );
135
+ return;
136
+ }
123
137
  }
124
138
  const switched = await pi.setModel(model);
125
139
  if (!switched) {
@@ -148,18 +162,18 @@ export function registerReview(pi: ExtensionAPI): void {
148
162
  // the runner, which may block on the model picker and the review turn.
149
163
  pi.registerCommand("third-review", {
150
164
  description:
151
- "Summon the configured review model to review the just-completed work (check + fix)",
165
+ "Pick the review model (defaults to the configured one) and review the just-completed work (check + fix)",
152
166
  handler: async (_args: string, ctx: ExtensionContext) => {
153
167
  await runReview(ctx);
154
168
  },
155
169
  });
156
170
 
157
171
  // Manual config entry point: view and change the review model. Always
158
- // opens the picker (title shows the current config); cancelling keeps
159
- // the existing config, so it is also safe as a view-only command.
172
+ // opens the /model-style picker (configured model preselected); cancelling
173
+ // keeps the existing config, so it is also safe as a view-only command.
160
174
  pi.registerCommand("review-model", {
161
175
  description:
162
- "Set or change the review model used by third-review (picker shows the current one)",
176
+ "Set or change the review model used by third-review (picker preselects the current one)",
163
177
  handler: async (_args: string, ctx: ExtensionContext) => {
164
178
  await pickReviewModel(ctx, getReviewStore());
165
179
  },
@@ -38,6 +38,7 @@ import {
38
38
  import {
39
39
  modelKey,
40
40
  resolveConfiguredModel,
41
+ type AvailableModel,
41
42
  } from "../../lib/model-config.js";
42
43
  import { REVIEW_PROMPT } from "./prompt.js";
43
44
  import {
@@ -98,10 +99,12 @@ function fakeCtx(
98
99
  models?: Model<Api>[];
99
100
  mode?: string;
100
101
  hasUI?: boolean;
102
+ customResult?: AvailableModel | undefined;
101
103
  selectResult?: string | undefined;
102
104
  } = {},
103
105
  ) {
104
106
  const notify = vi.fn();
107
+ const custom = vi.fn(async () => overrides.customResult);
105
108
  const select = vi.fn(async () => overrides.selectResult);
106
109
  const ctx = {
107
110
  mode: overrides.mode ?? "tui",
@@ -110,9 +113,9 @@ function fakeCtx(
110
113
  modelRegistry: {
111
114
  getAvailable: () => overrides.models ?? MODELS,
112
115
  } as unknown as ModelRegistry,
113
- ui: { notify, select },
116
+ ui: { notify, custom, select },
114
117
  } as unknown as ExtensionContext;
115
- return { ctx, notify, select };
118
+ return { ctx, notify, custom, select };
116
119
  }
117
120
 
118
121
  function fakePi(switched = true) {
@@ -228,34 +231,28 @@ describe("ReviewStore", () => {
228
231
  // ─── pickReviewModel ───────────────────────────────────────────
229
232
 
230
233
  describe("pickReviewModel", () => {
231
- it("shows the available models and persists the pick", async () => {
232
- const { ctx, select } = fakeCtx({
233
- selectResult: "GPT-5 (openrouter/openai/gpt-5)",
234
- });
234
+ it("opens the picker and persists the picked model", async () => {
235
235
  const store = new ReviewStore(freshStoreFile());
236
+ const { ctx, custom } = fakeCtx({ customResult: MODELS[2] });
236
237
  const picked = await pickReviewModel(ctx, store);
237
238
  expect(picked).toBe(MODELS[2]);
238
- expect(select).toHaveBeenCalledWith("选择评审模型 (third-review)", [
239
- "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
240
- "Claude Opus 4 (anthropic/claude-opus-4)",
241
- "GPT-5 (openrouter/openai/gpt-5)",
242
- ]);
239
+ expect(custom).toHaveBeenCalledTimes(1);
243
240
  expect(store.getModel()).toBe("openrouter/openai/gpt-5");
244
241
  });
245
242
 
246
243
  it("returns undefined and does not persist when cancelled", async () => {
247
- const { ctx, select } = fakeCtx({ selectResult: undefined });
248
244
  const store = new ReviewStore(freshStoreFile());
245
+ const { ctx, custom } = fakeCtx({ customResult: undefined });
249
246
  expect(await pickReviewModel(ctx, store)).toBeUndefined();
250
- expect(select).toHaveBeenCalledTimes(1);
247
+ expect(custom).toHaveBeenCalledTimes(1);
251
248
  expect(store.getModel()).toBeUndefined();
252
249
  });
253
250
 
254
251
  it("fails gracefully when no models are available", async () => {
255
- const { ctx, notify, select } = fakeCtx({ models: [] });
252
+ const { ctx, notify, custom } = fakeCtx({ models: [] });
256
253
  const store = new ReviewStore(freshStoreFile());
257
254
  expect(await pickReviewModel(ctx, store)).toBeUndefined();
258
- expect(select).not.toHaveBeenCalled();
255
+ expect(custom).not.toHaveBeenCalled();
259
256
  expect(notify).toHaveBeenCalledWith(
260
257
  expect.stringContaining("没有可用模型"),
261
258
  "error",
@@ -263,39 +260,72 @@ describe("pickReviewModel", () => {
263
260
  });
264
261
 
265
262
  it("refuses when no dialog-capable UI is available", async () => {
266
- const { ctx, notify, select } = fakeCtx({ hasUI: false });
263
+ const { ctx, notify, custom } = fakeCtx({ hasUI: false });
267
264
  const store = new ReviewStore(freshStoreFile());
268
265
  expect(await pickReviewModel(ctx, store)).toBeUndefined();
269
- expect(select).not.toHaveBeenCalled();
266
+ expect(custom).not.toHaveBeenCalled();
270
267
  expect(notify).toHaveBeenCalledWith(
271
268
  expect.stringContaining("不支持模型选择对话框"),
272
269
  "error",
273
270
  );
274
271
  });
275
272
 
276
- it("works in rpc mode (dialogs are available there)", async () => {
277
- const { ctx, select } = fakeCtx({
273
+ it("works in rpc mode via the host selector", async () => {
274
+ const store = new ReviewStore(freshStoreFile());
275
+ const { ctx, custom, select } = fakeCtx({
278
276
  mode: "rpc",
279
277
  selectResult: "Claude Opus 4 (anthropic/claude-opus-4)",
280
278
  });
281
- const store = new ReviewStore(freshStoreFile());
282
279
  const picked = await pickReviewModel(ctx, store);
283
280
  expect(picked).toBe(MODELS[1]);
281
+ // rpc cannot host custom components — the host renders select instead.
282
+ expect(custom).not.toHaveBeenCalled();
284
283
  expect(select).toHaveBeenCalledTimes(1);
285
284
  expect(store.getModel()).toBe("anthropic/claude-opus-4");
286
285
  });
287
286
 
288
- it("shows the current config in the picker title", async () => {
287
+ it("keeps the config untouched when cancelled", async () => {
289
288
  const store = new ReviewStore(freshStoreFile());
290
- store.setModel("anthropic/claude-sonnet-4");
291
- const { ctx, select } = fakeCtx({ selectResult: undefined });
289
+ store.setModel("anthropic/claude-opus-4");
290
+ const { ctx, custom } = fakeCtx({ customResult: undefined });
292
291
  await pickReviewModel(ctx, store);
293
- expect(select).toHaveBeenCalledWith(
294
- "选择评审模型 (当前: anthropic/claude-sonnet-4)",
295
- expect.any(Array),
296
- );
292
+ expect(custom).toHaveBeenCalledTimes(1);
297
293
  // Cancelling a change keeps the existing config.
298
- expect(store.getModel()).toBe("anthropic/claude-sonnet-4");
294
+ expect(store.getModel()).toBe("anthropic/claude-opus-4");
295
+ });
296
+
297
+ it("warns when the configured model is unavailable", async () => {
298
+ const store = new ReviewStore(freshStoreFile());
299
+ store.setModel("anthropic/claude-opus-4-5"); // deleted in pi
300
+ const { ctx, notify } = fakeCtx({ customResult: undefined });
301
+ await pickReviewModel(ctx, store);
302
+ expect(notify).toHaveBeenCalledWith(
303
+ expect.stringContaining("不存在或不可用"),
304
+ "warning",
305
+ );
306
+ });
307
+
308
+ it("re-picking the configured model does not rewrite the store", async () => {
309
+ const store = new ReviewStore(freshStoreFile());
310
+ store.setModel("anthropic/claude-opus-4");
311
+ const spy = vi.spyOn(store, "setModel");
312
+ const { ctx } = fakeCtx({ customResult: MODELS[1] });
313
+ const picked = await pickReviewModel(ctx, store);
314
+ expect(picked).toBe(MODELS[1]);
315
+ expect(spy).not.toHaveBeenCalled();
316
+ });
317
+
318
+ it("persists and confirms when a different model is picked", async () => {
319
+ const store = new ReviewStore(freshStoreFile());
320
+ store.setModel("anthropic/claude-opus-4");
321
+ const { ctx, notify } = fakeCtx({ customResult: MODELS[2] });
322
+ const picked = await pickReviewModel(ctx, store);
323
+ expect(picked).toBe(MODELS[2]);
324
+ expect(store.getModel()).toBe("openrouter/openai/gpt-5");
325
+ expect(notify).toHaveBeenCalledWith(
326
+ expect.stringContaining("已保存为"),
327
+ "info",
328
+ );
299
329
  });
300
330
  });
301
331
 
@@ -314,11 +344,13 @@ describe("createReviewRunner", () => {
314
344
  expect(pi.sendUserMessage).not.toHaveBeenCalled();
315
345
  });
316
346
 
317
- it("switches to the configured model and sends the review prompt", async () => {
347
+ it("shows the picker and runs on the configured model when accepted", async () => {
318
348
  getReviewStore().setModel("anthropic/claude-sonnet-4");
319
349
  const { pi, setModel, sendUserMessage } = fakePi();
320
- const { ctx, notify } = fakeCtx();
350
+ const { ctx, notify, custom } = fakeCtx({ customResult: MODELS[0] });
321
351
  await createReviewRunner(pi)(ctx);
352
+ // The picker is always shown, even with a configured model.
353
+ expect(custom).toHaveBeenCalledTimes(1);
322
354
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
323
355
  expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
324
356
  expect(notify).toHaveBeenCalledWith(
@@ -327,28 +359,30 @@ describe("createReviewRunner", () => {
327
359
  );
328
360
  });
329
361
 
362
+ it("runs with the configured model without dialogs when the UI cannot pick", async () => {
363
+ getReviewStore().setModel("anthropic/claude-sonnet-4");
364
+ const { pi, setModel, sendUserMessage } = fakePi();
365
+ const { ctx, custom } = fakeCtx({ hasUI: false });
366
+ await createReviewRunner(pi)(ctx);
367
+ expect(custom).not.toHaveBeenCalled();
368
+ expect(setModel).toHaveBeenCalledWith(MODELS[0]);
369
+ expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
370
+ });
371
+
330
372
  it("picks, persists and runs when no model is configured", async () => {
331
373
  const { pi, setModel, sendUserMessage } = fakePi();
332
- const { ctx, notify, select } = fakeCtx({
333
- selectResult: "GPT-5 (openrouter/openai/gpt-5)",
334
- });
374
+ const { ctx, custom } = fakeCtx({ customResult: MODELS[2] });
335
375
  await createReviewRunner(pi)(ctx);
336
376
  expect(getReviewStore().getModel()).toBe("openrouter/openai/gpt-5");
337
377
  expect(setModel).toHaveBeenCalledWith(MODELS[2]);
338
378
  expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
339
- expect(select).toHaveBeenCalledTimes(1);
340
- expect(notify).toHaveBeenCalledWith(
341
- expect.stringContaining("尚未配置评审模型"),
342
- "warning",
343
- );
379
+ expect(custom).toHaveBeenCalledTimes(1);
344
380
  });
345
381
 
346
382
  it("re-picks, persists and runs when the configured model is unavailable", async () => {
347
383
  getReviewStore().setModel("anthropic/claude-opus-4-5"); // deleted in pi
348
384
  const { pi, setModel, sendUserMessage } = fakePi();
349
- const { ctx, notify, select } = fakeCtx({
350
- selectResult: "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
351
- });
385
+ const { ctx, notify, custom } = fakeCtx({ customResult: MODELS[0] });
352
386
  await createReviewRunner(pi)(ctx);
353
387
  expect(notify).toHaveBeenCalledWith(
354
388
  expect.stringContaining("不存在或不可用"),
@@ -357,14 +391,14 @@ describe("createReviewRunner", () => {
357
391
  expect(getReviewStore().getModel()).toBe("anthropic/claude-sonnet-4");
358
392
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
359
393
  expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
360
- expect(select).toHaveBeenCalledTimes(1);
394
+ expect(custom).toHaveBeenCalledTimes(1);
361
395
  });
362
396
 
363
397
  it("aborts when the user cancels the model pick", async () => {
364
398
  const { pi, setModel, sendUserMessage } = fakePi();
365
- const { ctx, select } = fakeCtx({ selectResult: undefined });
399
+ const { ctx, custom } = fakeCtx({ customResult: undefined });
366
400
  await createReviewRunner(pi)(ctx);
367
- expect(select).toHaveBeenCalledTimes(1);
401
+ expect(custom).toHaveBeenCalledTimes(1);
368
402
  expect(setModel).not.toHaveBeenCalled();
369
403
  expect(sendUserMessage).not.toHaveBeenCalled();
370
404
  expect(getReviewStore().getModel()).toBeUndefined();
@@ -373,7 +407,7 @@ describe("createReviewRunner", () => {
373
407
  it("reports and aborts when switching to the review model fails", async () => {
374
408
  getReviewStore().setModel("anthropic/claude-sonnet-4");
375
409
  const { pi, setModel, sendUserMessage } = fakePi(false); // setModel → false
376
- const { ctx, notify } = fakeCtx();
410
+ const { ctx, notify } = fakeCtx({ customResult: MODELS[0] });
377
411
  await createReviewRunner(pi)(ctx);
378
412
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
379
413
  expect(sendUserMessage).not.toHaveBeenCalled();
@@ -385,10 +419,10 @@ describe("createReviewRunner", () => {
385
419
 
386
420
  it("serializes concurrent triggers (one review at a time)", async () => {
387
421
  const { pi } = fakePi();
388
- // First run blocks inside ui.select until we resolve it.
389
- let release: ((v: string | undefined) => void) | undefined;
390
- const select = vi.fn(
391
- () => new Promise<string | undefined>((resolve) => (release = resolve)),
422
+ // First run blocks inside the picker until we resolve it.
423
+ let release: ((v: AvailableModel | undefined) => void) | undefined;
424
+ const custom = vi.fn(
425
+ () => new Promise<AvailableModel | undefined>((resolve) => (release = resolve)),
392
426
  );
393
427
  const ctx = {
394
428
  mode: "tui",
@@ -397,7 +431,7 @@ describe("createReviewRunner", () => {
397
431
  modelRegistry: {
398
432
  getAvailable: () => MODELS,
399
433
  } as unknown as ModelRegistry,
400
- ui: { notify: vi.fn(), select },
434
+ ui: { notify: vi.fn(), custom },
401
435
  } as unknown as ExtensionContext;
402
436
 
403
437
  const runner = createReviewRunner(pi);
@@ -409,14 +443,14 @@ describe("createReviewRunner", () => {
409
443
  hasUI: true,
410
444
  isIdle: () => true,
411
445
  modelRegistry: { getAvailable: () => MODELS } as unknown as ModelRegistry,
412
- ui: { notify: notify2, select: vi.fn(async () => undefined) },
446
+ ui: { notify: notify2, custom: vi.fn(async () => undefined) },
413
447
  } as unknown as ExtensionContext;
414
448
  await runner(ctx2);
415
449
  expect(notify2).toHaveBeenCalledWith(
416
450
  expect.stringContaining("评审已在执行中"),
417
451
  "warning",
418
452
  );
419
- release?.("Claude Sonnet 4 (anthropic/claude-sonnet-4)");
453
+ release?.(MODELS[0]);
420
454
  await first;
421
455
  expect(pi.setModel).toHaveBeenCalledTimes(1);
422
456
  expect(pi.sendUserMessage).toHaveBeenCalledTimes(1);
@@ -0,0 +1,191 @@
1
+ /**
2
+ * model-picker — tests.
3
+ *
4
+ * Mocks ModelSelectorComponent (the real built-in /model component) to
5
+ * capture how openModelPicker drives it: the preselected currentModel, the
6
+ * modelRuntime facade wiring over the registry, and select/cancel
7
+ * resolution. The preselection + scrolling list behavior itself belongs to
8
+ * pi's component (covered by pi) — here we assert OUR wiring.
9
+ */
10
+ import { beforeEach, describe, expect, it, vi } from "vitest";
11
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type { AvailableModel } from "./model-config.js";
13
+ import { openModelPicker } from "./model-picker.js";
14
+
15
+ const { instances } = vi.hoisted(() => ({
16
+ instances: [] as Array<{
17
+ currentModel: unknown;
18
+ modelRuntime: unknown;
19
+ onSelect: (model: unknown) => void;
20
+ onCancel: () => void;
21
+ }>,
22
+ }));
23
+
24
+ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
25
+ const actual =
26
+ await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
27
+ class FakeSelector {
28
+ currentModel: unknown;
29
+ modelRuntime: unknown;
30
+ onSelect: (model: unknown) => void;
31
+ onCancel: () => void;
32
+ constructor(
33
+ _tui: unknown,
34
+ currentModel: unknown,
35
+ modelRuntime: unknown,
36
+ _scopedModels: unknown,
37
+ onSelect: (model: unknown) => void,
38
+ onCancel: () => void,
39
+ ) {
40
+ this.currentModel = currentModel;
41
+ this.modelRuntime = modelRuntime;
42
+ this.onSelect = onSelect;
43
+ this.onCancel = onCancel;
44
+ instances.push(this);
45
+ }
46
+ dispose(): void {}
47
+ }
48
+ return { ...actual, ModelSelectorComponent: FakeSelector };
49
+ });
50
+
51
+ function fakeModel(provider: string, id: string, name = id): AvailableModel {
52
+ return {
53
+ id,
54
+ name,
55
+ provider,
56
+ api: "anthropic-messages",
57
+ baseUrl: "https://api.anthropic.com",
58
+ reasoning: false,
59
+ input: ["text"],
60
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
61
+ contextWindow: 200000,
62
+ maxTokens: 8192,
63
+ } as AvailableModel;
64
+ }
65
+
66
+ const MODELS: AvailableModel[] = [
67
+ fakeModel("google", "gemini-2.5-flash", "Gemini 2.5 Flash"),
68
+ fakeModel("anthropic", "claude-sonnet-4", "Claude Sonnet 4"),
69
+ ];
70
+
71
+ function fakeCtx(models: AvailableModel[] = MODELS) {
72
+ const notify = vi.fn();
73
+ const custom = vi.fn((factory: (...args: unknown[]) => unknown) => {
74
+ // Mimic pi's mounting: the factory runs, constructing the component;
75
+ // the component's done() callback resolves the dialog.
76
+ return new Promise((resolve) => {
77
+ factory(
78
+ { requestRender: vi.fn() },
79
+ undefined,
80
+ undefined,
81
+ (result: unknown) => resolve(result),
82
+ );
83
+ });
84
+ });
85
+ const select = vi.fn(async () => undefined as string | undefined);
86
+ const ctx = {
87
+ hasUI: true,
88
+ mode: "tui",
89
+ model: undefined,
90
+ modelRegistry: {
91
+ getAvailable: () => models,
92
+ find: (provider: string, id: string) =>
93
+ models.find((m) => m.provider === provider && m.id === id),
94
+ getError: () => undefined,
95
+ refresh: async () => ({ aborted: false, errors: new Map() }),
96
+ },
97
+ ui: { notify, custom, select },
98
+ } as unknown as ExtensionContext;
99
+ return { ctx, notify, custom, select };
100
+ }
101
+
102
+ describe("openModelPicker", () => {
103
+ beforeEach(() => {
104
+ instances.length = 0;
105
+ });
106
+
107
+ it("resolves undefined without dialogs when the UI cannot pick", async () => {
108
+ const { ctx, custom } = fakeCtx();
109
+ (ctx as { hasUI: boolean }).hasUI = false;
110
+ expect(await openModelPicker(ctx)).toBeUndefined();
111
+ expect(custom).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it("notifies when no models are available", async () => {
115
+ const { ctx, notify } = fakeCtx([]);
116
+ expect(await openModelPicker(ctx)).toBeUndefined();
117
+ expect(notify).toHaveBeenCalledWith(
118
+ expect.stringContaining("没有可用模型"),
119
+ "error",
120
+ );
121
+ });
122
+
123
+ it("preselects the current model as the component's currentModel", async () => {
124
+ const { ctx } = fakeCtx();
125
+ const promise = openModelPicker(ctx, MODELS[1]);
126
+ const selector = instances.at(-1)!;
127
+ expect(selector.currentModel).toBe(MODELS[1]);
128
+ selector.onSelect(MODELS[0]);
129
+ expect(await promise).toBe(MODELS[0]);
130
+ });
131
+
132
+ it("falls back to the session model when nothing is configured", async () => {
133
+ const { ctx } = fakeCtx();
134
+ const sessionModel = MODELS[0];
135
+ (ctx as { model: unknown }).model = sessionModel;
136
+ const promise = openModelPicker(ctx);
137
+ const selector = instances.at(-1)!;
138
+ expect(selector.currentModel).toBe(sessionModel);
139
+ selector.onCancel();
140
+ expect(await promise).toBeUndefined();
141
+ });
142
+
143
+ it("backs the component with a modelRuntime facade over the registry", async () => {
144
+ const { ctx } = fakeCtx();
145
+ const promise = openModelPicker(ctx, MODELS[0]);
146
+ const selector = instances.at(-1)!;
147
+ const runtime = selector.modelRuntime as {
148
+ getAvailableSnapshot(): AvailableModel[];
149
+ getModel(p: string, id: string): AvailableModel | undefined;
150
+ getError(): string | undefined;
151
+ refresh(o?: {
152
+ signal?: AbortSignal;
153
+ }): Promise<{ aborted: boolean; errors: Map<string, Error> }>;
154
+ };
155
+ expect(runtime.getAvailableSnapshot()).toEqual(MODELS);
156
+ expect(runtime.getModel("google", "gemini-2.5-flash")).toBe(MODELS[0]);
157
+ expect(runtime.getError()).toBeUndefined();
158
+ await expect(runtime.refresh()).resolves.toEqual({
159
+ aborted: false,
160
+ errors: new Map(),
161
+ });
162
+ selector.onCancel();
163
+ await promise;
164
+ });
165
+
166
+ it("falls back to the host selector in rpc mode (default first)", async () => {
167
+ const { ctx, custom, select } = fakeCtx();
168
+ (ctx as { mode: string }).mode = "rpc";
169
+ select.mockResolvedValue("Claude Sonnet 4 (anthropic/claude-sonnet-4)");
170
+ const picked = await openModelPicker(ctx, MODELS[1]);
171
+ expect(custom).not.toHaveBeenCalled();
172
+ expect(select).toHaveBeenCalledWith(
173
+ "选择模型(首项为当前默认,回车使用)",
174
+ [
175
+ "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
176
+ "Gemini 2.5 Flash (google/gemini-2.5-flash)",
177
+ ],
178
+ );
179
+ expect(picked).toBe(MODELS[1]);
180
+ });
181
+
182
+ it("uses the provided select title in rpc mode and treats cancel as undefined", async () => {
183
+ const { ctx, select } = fakeCtx();
184
+ (ctx as { mode: string }).mode = "rpc";
185
+ select.mockResolvedValue(undefined);
186
+ expect(
187
+ await openModelPicker(ctx, MODELS[0], { selectTitle: "选择评审模型" }),
188
+ ).toBeUndefined();
189
+ expect(select).toHaveBeenCalledWith("选择评审模型", expect.any(Array));
190
+ });
191
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * /model-style model picker for extension features (review, compact-model).
3
+ *
4
+ * Reuses pi's REAL built-in ModelSelectorComponent via ctx.ui.custom instead
5
+ * of ctx.ui.select: the extension select dialog (ExtensionSelectorComponent)
6
+ * renders every option unbounded — with many models it overflows the
7
+ * terminal — while ModelSelectorComponent is the actual /model experience:
8
+ * height-limited scrolling list, fuzzy search, current-model marker.
9
+ *
10
+ * The component is driven through a small ModelRuntime facade backed by
11
+ * ctx.modelRegistry (pi's extension-facing wrapper). SAFETY: verified
12
+ * against pi 0.87.1 dist (modes/interactive/components/model-selector.js +
13
+ * model-catalog-refresh.js) — the component only calls getAvailableSnapshot,
14
+ * getModel, getError and refresh on modelRuntime, and ModelRegistry provides
15
+ * exactly those (getAvailable/find/getError/refresh).
16
+ *
17
+ * `current` is passed as the component's currentModel: the built-in selector
18
+ * preselects, ✓-marks and sorts-first its currentModel, so one Enter accepts
19
+ * it. Enter resolves with the chosen model; Esc resolves undefined.
20
+ *
21
+ * Non-TUI dialog modes (rpc) cannot host custom components — pi's rpc
22
+ * ui.custom() resolves undefined immediately — so the host-rendered
23
+ * ctx.ui.select is used there with the default as the first option
24
+ * (ExtensionContext.mode docs: "Use 'tui' to guard terminal-only UI such as
25
+ * custom components").
26
+ */
27
+ import {
28
+ ModelRuntime,
29
+ ModelSelectorComponent,
30
+ type ExtensionContext,
31
+ } from "@earendil-works/pi-coding-agent";
32
+ import { modelKey, type AvailableModel } from "./model-config.js";
33
+
34
+ /** ModelRuntime facade backed by the extension-facing ModelRegistry. */
35
+ function modelRuntimeFacade(ctx: ExtensionContext): ModelRuntime {
36
+ const facade = {
37
+ getAvailableSnapshot: () => ctx.modelRegistry.getAvailable(),
38
+ getModel: (providerId: string, modelId: string) =>
39
+ ctx.modelRegistry.find(providerId, modelId),
40
+ getError: () => ctx.modelRegistry.getError(),
41
+ refresh: (options?: { signal?: AbortSignal }) =>
42
+ ctx.modelRegistry.refresh(options),
43
+ };
44
+ // SAFETY: the selector only touches the four members above (pi 0.87.1
45
+ // model-selector.js); everything else on ModelRuntime is never reached
46
+ // from this component.
47
+ return facade as unknown as ModelRuntime;
48
+ }
49
+
50
+ /**
51
+ * Open the picker. `current` (the preselected/default model) is highlighted
52
+ * and ✓-marked in TUI mode — one Enter accepts it. In dialog-capable modes
53
+ * without custom-component support (rpc), the host renders a plain selector
54
+ * instead, with `current` as the first option (Enter accepts it too).
55
+ * Resolves with the picked model, or undefined when the user cancels.
56
+ */
57
+ export async function openModelPicker(
58
+ ctx: ExtensionContext,
59
+ current?: AvailableModel,
60
+ options?: { selectTitle?: string },
61
+ ): Promise<AvailableModel | undefined> {
62
+ if (!ctx.hasUI) return undefined;
63
+ const available = ctx.modelRegistry.getAvailable();
64
+ if (available.length === 0) {
65
+ ctx.ui.notify("当前没有可用模型,请先在 pi 中配置模型。", "error");
66
+ return undefined;
67
+ }
68
+ if (ctx.mode === "tui") {
69
+ // The real built-in /model component: height-limited scrolling list +
70
+ // fuzzy search (ctx.ui.select renders every option unbounded).
71
+ return ctx.ui.custom<AvailableModel | undefined>(
72
+ (tui, _theme, _keybindings, done) =>
73
+ new ModelSelectorComponent(
74
+ tui,
75
+ current ?? ctx.model,
76
+ modelRuntimeFacade(ctx),
77
+ [],
78
+ (model) => done(model),
79
+ () => done(undefined),
80
+ ),
81
+ );
82
+ }
83
+ // rpc and other dialog-capable modes: custom components are TUI-only
84
+ // (rpc's ui.custom() resolves undefined immediately), but the host
85
+ // renders select dialogs itself — default first so Enter accepts it.
86
+ const ordered: AvailableModel[] = current
87
+ ? [
88
+ current,
89
+ ...available.filter((m) => modelKey(m) !== modelKey(current)),
90
+ ]
91
+ : available;
92
+ const labels = ordered.map(
93
+ (m: AvailableModel) => `${m.name} (${m.provider}/${m.id})`,
94
+ );
95
+ const choice = await ctx.ui.select(
96
+ options?.selectTitle ?? "选择模型(首项为当前默认,回车使用)",
97
+ labels,
98
+ );
99
+ if (choice === undefined) return undefined;
100
+ return ordered[labels.indexOf(choice)];
101
+ }