@andares/pi-toolkits 0.4.1 → 0.4.3

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
@@ -163,8 +163,8 @@ pi -e ./src/index.ts
163
163
 
164
164
  **评审模型选择**(每次 `/third-review` 都会弹出选择界面):
165
165
 
166
- - `/third-review` → 弹出模型列表(与 `/model` 同源):**第一项 = 当前配置的模型**(默认高亮),直接**回车**即用它开始评审;也可移动到其他模型
167
- - **换选其他模型** → 立即写回配置(**落盘 + 内存**),**下次默认使用该模型**;标题实时显示当前配置
166
+ - `/third-review` → 弹出 **`/model` 同款选择器**(限高滚动 + 模糊搜索):**默认选中当前配置的模型**(✓ 标记),直接**回车**即用它开始评审
167
+ - **换选其他模型** → 立即写回配置(**落盘 + 内存**),**下次默认使用该模型**
168
168
  - **取消**(Esc)→ 不执行评审,配置不变
169
169
  - 配置持久化于 `<agent-dir>/pi-toolkits-review.json`(默认 `~/.pi/agent/`):
170
170
 
@@ -206,7 +206,7 @@ pi 的 `/compact` 与 auto-compact 默认**用当前会话模型**做上下文
206
206
  { "model": "google/gemini-2.5-flash" }
207
207
  ```
208
208
 
209
- - `/compact-model` —— 查看/更换/清除:弹出可用模型列表(标题显示当前配置,首项「✕ 清除配置」),选中即写入;取消则不修改
209
+ - `/compact-model` —— 查看/更换:`/model` 同款选择器(限高滚动 + 模糊搜索),**默认选中当前配置的模型**,回车即确认;换选即写入;取消则不变(清除配置:编辑 `pi-toolkits-compact.json` 把 `model` 置为 `null`)
210
210
  - 未配置 = 功能未启用,全部走 pi 默认,零开销;footer 出现灰色 `compact` 状态 = 已配置
211
211
 
212
212
  **无损回落**(本功能永远不会把压缩「做挂」):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andares/pi-toolkits",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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,
@@ -249,8 +251,8 @@ async function runCompactModelCompaction(
249
251
  }
250
252
 
251
253
  /**
252
- * Let the user pick a compact model (or clear the config) and persist the
253
- * 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.
254
256
  */
255
257
  export async function pickCompactModel(
256
258
  ctx: ExtensionContext,
@@ -263,45 +265,31 @@ export async function pickCompactModel(
263
265
  );
264
266
  return undefined;
265
267
  }
266
- const available = ctx.modelRegistry.getAvailable();
267
- if (available.length === 0) {
268
+ const configured = store.getModel();
269
+ const configuredModel = resolveConfiguredModel(ctx, configured);
270
+ if (!configuredModel && configured) {
268
271
  ctx.ui.notify(
269
- "compact-model: 当前没有可用模型,请先在 pi 中配置模型。",
270
- "error",
272
+ `compact-model: 配置的压缩模型 ${configured} 不存在或不可用,请重新选择。`,
273
+ "warning",
271
274
  );
272
- return undefined;
273
275
  }
274
- const configured = store.getModel();
275
- const clearLabel = "✕ 清除配置(用 pi 默认压缩)";
276
- const options = [
277
- clearLabel,
278
- ...available.map(
279
- (m: AvailableModel) => `${m.name} (${m.provider}/${m.id})`,
280
- ),
281
- ];
282
- const choice = await ctx.ui.select(
283
- configured
284
- ? `选择压缩模型 (当前: ${configured})`
285
- : "选择压缩模型 (compact-model)",
286
- options,
287
- );
288
- if (choice === undefined) {
289
- ctx.ui.notify("compact-model: 已取消,压缩模型配置未变更。", "info");
290
- return undefined;
291
- }
292
- if (choice === clearLabel) {
293
- store.setModel(undefined);
276
+ const model = await openModelPicker(ctx, configuredModel, {
277
+ selectTitle: "选择压缩模型(首项为当前默认,回车使用)",
278
+ });
279
+ if (!model) {
294
280
  ctx.ui.notify(
295
- "compact-model: 已清除配置,压缩恢复 pi 默认(当前会话模型)。",
281
+ "compact-model: 已取消,压缩模型配置未变更。清除配置请编辑 <agent-dir>/pi-toolkits-compact.json(把 model 置为 null)。",
296
282
  "info",
297
283
  );
298
284
  return undefined;
299
285
  }
300
- const model = available[options.indexOf(choice) - 1];
301
- if (!model) return undefined;
302
- 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
+ }
303
291
  ctx.ui.notify(
304
- `compact-model: 已设置压缩模型「${model.name}」,后续压缩总结将由它执行。`,
292
+ `compact-model: 压缩模型已设置为「${model.name}」,后续压缩总结将由它执行。`,
305
293
  "info",
306
294
  );
307
295
  return model;
@@ -330,10 +318,10 @@ export function registerCompact(pi: ExtensionAPI): void {
330
318
  },
331
319
  );
332
320
 
333
- // Config entry point: view, change, or clear the compact model.
321
+ // Config entry point: view and change the compact model.
334
322
  pi.registerCommand("compact-model", {
335
323
  description:
336
- "Set, change, or clear the dedicated model used for /compact summaries",
324
+ "Set or change the dedicated model used for /compact summaries",
337
325
  handler: async (_args: string, ctx: ExtensionContext) => {
338
326
  await pickCompactModel(ctx, getCompactStore());
339
327
  },
@@ -23,7 +23,15 @@ import {
23
23
  ModelSelectorComponent,
24
24
  type Theme,
25
25
  } from "@earendil-works/pi-coding-agent";
26
- import { afterAll, beforeEach, beforeAll, describe, expect, it } from "vitest";
26
+ import {
27
+ afterAll,
28
+ beforeEach,
29
+ beforeAll,
30
+ describe,
31
+ expect,
32
+ it,
33
+ vi,
34
+ } from "vitest";
27
35
  import {
28
36
  FavoritesStore,
29
37
  getFavoritesStore,
@@ -102,14 +110,17 @@ describe("FavoritesStore", () => {
102
110
  it("falls back to empty + defaults on a corrupted file", () => {
103
111
  const file = freshStoreFile();
104
112
  // Intentionally write invalid JSON — the READ side must tolerate it.
113
+ writeFileSync(file, "{ not json", "utf8");
114
+ // Suppress the designed degradation log; the spy asserts it fired.
115
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
105
116
  try {
106
- writeFileSync(file, "{ not json", "utf8");
107
- } catch {
108
- // A failed write still leaves the store to hit the read-error path.
117
+ const store = new FavoritesStore(file);
118
+ expect(store.count()).toBe(0);
119
+ expect(store.getConfig().cycleOnlyFavorites).toBe(true);
120
+ expect(warn).toHaveBeenCalled();
121
+ } finally {
122
+ warn.mockRestore();
109
123
  }
110
- const store = new FavoritesStore(file);
111
- expect(store.count()).toBe(0);
112
- expect(store.getConfig().cycleOnlyFavorites).toBe(true);
113
124
  });
114
125
 
115
126
  it("loads cycleOnlyFavorites config from disk", () => {
@@ -12,13 +12,14 @@
12
12
  *
13
13
  * Review-model config lives in `<agent-dir>/pi-toolkits-review.json`
14
14
  * ("provider/id", see store.ts) and doubles as the picker default. Every
15
- * /third-review run opens the model picker (ctx.ui.select, same
16
- * getAvailable() source as /model) with the configured model as the FIRST
17
- * row — one Enter accepts it and starts the review. Picking a different
18
- * model persists it immediately (disk + in-memory) as the new default for
19
- * next time; cancelling aborts the review without changing anything. When
20
- * the configured model is gone/unavailable (e.g. deleted from pi's
21
- * models.json) the user is warned and the picker opens on the plain list.
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.
22
23
  *
23
24
  * Guards: never fires while the agent is streaming (the review needs the
24
25
  * just-finished work as context) and never runs two reviews concurrently.
@@ -34,19 +35,19 @@ import {
34
35
  resolveConfiguredModel,
35
36
  type AvailableModel,
36
37
  } from "../../lib/model-config.js";
38
+ import { openModelPicker } from "../../lib/model-picker.js";
37
39
  import { REVIEW_STATUS_KEY } from "./constants.js";
38
40
  import { REVIEW_PROMPT } from "./prompt.js";
39
41
  import { getReviewStore, type ReviewStore } from "./store.js";
40
42
 
41
43
  /**
42
- * Let the user pick a review model from the available list and persist the
43
- * 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.
44
46
  *
45
- * The configured model is placed FIRST: the select dialog always highlights
46
- * its first row, so one Enter accepts the current default. Picking any other
47
- * model persists it (disk + in-memory) as the new default. The title shows
48
- * the current config, so /review-model doubles as a view-and-change entry
49
- * point; cancelling leaves the config untouched.
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
50
+ * view-and-change entry point; cancelling leaves the config untouched.
50
51
  */
51
52
  export async function pickReviewModel(
52
53
  ctx: ExtensionContext,
@@ -61,45 +62,21 @@ export async function pickReviewModel(
61
62
  );
62
63
  return undefined;
63
64
  }
64
- const available = ctx.modelRegistry.getAvailable();
65
- if (available.length === 0) {
66
- ctx.ui.notify(
67
- "third-review: 当前没有可用模型,请先在 pi 中配置模型。",
68
- "error",
69
- );
70
- return undefined;
71
- }
72
65
  const configured = store.getModel();
73
66
  const configuredModel = resolveConfiguredModel(ctx, configured);
74
- // Configured model first → highlighted by default (Enter accepts it);
75
- // the rest keep the registry order.
76
- const ordered: AvailableModel[] = configuredModel
77
- ? [
78
- configuredModel,
79
- ...available.filter(
80
- (m) => modelKey(m) !== modelKey(configuredModel),
81
- ),
82
- ]
83
- : available;
84
- // Same display source as the /model selector: name + [provider]/id.
85
- const options = ordered.map(
86
- (m: AvailableModel) => `${m.name} (${m.provider}/${m.id})`,
87
- );
88
- // Title reflects what Enter actually accepts: the configured model when it
89
- // is available, a clear "unavailable" marker otherwise.
90
- let title = "选择评审模型 (third-review)";
91
- if (configuredModel) {
92
- title = `选择评审模型 (当前: ${configured},回车使用)`;
93
- } else if (configured) {
94
- title = `选择评审模型 (当前: ${configured} 不可用)`;
67
+ if (!configuredModel && configured) {
68
+ ctx.ui.notify(
69
+ `third-review: 配置的评审模型 ${configured} 不存在或不可用,请重新选择。`,
70
+ "warning",
71
+ );
95
72
  }
96
- const choice = await ctx.ui.select(title, options);
97
- if (choice === undefined) {
73
+ const model = await openModelPicker(ctx, configuredModel, {
74
+ selectTitle: "选择评审模型(首项为当前默认,回车使用)",
75
+ });
76
+ if (!model) {
98
77
  ctx.ui.notify("third-review: 已取消,评审模型配置未变更。", "info");
99
78
  return undefined;
100
79
  }
101
- const model = ordered[options.indexOf(choice)];
102
- if (!model) return undefined;
103
80
  const key = modelKey(model);
104
81
  if (key !== configured) {
105
82
  // Persist immediately (disk + in-memory + footer) — next run defaults here.
@@ -139,29 +116,24 @@ export function createReviewRunner(
139
116
  running = true;
140
117
  try {
141
118
  const store = getReviewStore();
142
- const configured = store.getModel();
143
- let model = resolveConfiguredModel(ctx, configured);
144
- if (!model) {
145
- ctx.ui.notify(
146
- configured
147
- ? `third-review: 配置的评审模型 ${configured} 不存在或不可用,请重新选择。`
148
- : "third-review: 尚未配置评审模型,请选择。",
149
- "warning",
150
- );
151
- }
119
+ let model: AvailableModel | undefined;
152
120
  if (ctx.hasUI) {
153
- // Always show the picker: the configured model is the first row
154
- // (one Enter accepts it); picking another persists it as default.
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.)
155
124
  model = await pickReviewModel(ctx, store);
156
125
  if (!model) return;
157
- } else if (!model) {
126
+ } else {
158
127
  // No dialogs (json/print/headless): the configured-model path keeps
159
128
  // working; without one, point at the config file.
160
- ctx.ui.notify(
161
- "third-review: 当前环境不支持模型选择对话框,请编辑 <agent-dir>/pi-toolkits-review.json 手动配置。",
162
- "error",
163
- );
164
- return;
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
+ }
165
137
  }
166
138
  const switched = await pi.setModel(model);
167
139
  if (!switched) {
@@ -190,18 +162,18 @@ export function registerReview(pi: ExtensionAPI): void {
190
162
  // the runner, which may block on the model picker and the review turn.
191
163
  pi.registerCommand("third-review", {
192
164
  description:
193
- "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)",
194
166
  handler: async (_args: string, ctx: ExtensionContext) => {
195
167
  await runReview(ctx);
196
168
  },
197
169
  });
198
170
 
199
171
  // Manual config entry point: view and change the review model. Always
200
- // opens the picker (title shows the current config); cancelling keeps
201
- // 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.
202
174
  pi.registerCommand("review-model", {
203
175
  description:
204
- "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)",
205
177
  handler: async (_args: string, ctx: ExtensionContext) => {
206
178
  await pickReviewModel(ctx, getReviewStore());
207
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) {
@@ -201,13 +204,15 @@ describe("ReviewStore", () => {
201
204
 
202
205
  it("falls back to unset on a corrupted file", () => {
203
206
  const file = freshStoreFile();
207
+ writeFileSync(file, "{ not json", "utf8");
208
+ // Suppress the designed degradation log; the spy asserts it fired.
209
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
204
210
  try {
205
- writeFileSync(file, "{ not json", "utf8");
206
- } catch {
207
- // A failed write still leaves the store to hit the read-error path.
211
+ expect(new ReviewStore(file).getModel()).toBeUndefined();
212
+ expect(warn).toHaveBeenCalled();
213
+ } finally {
214
+ warn.mockRestore();
208
215
  }
209
- const store = new ReviewStore(file);
210
- expect(store.getModel()).toBeUndefined();
211
216
  });
212
217
 
213
218
  it("ignores a stored value without a slash", () => {
@@ -228,34 +233,28 @@ describe("ReviewStore", () => {
228
233
  // ─── pickReviewModel ───────────────────────────────────────────
229
234
 
230
235
  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
- });
236
+ it("opens the picker and persists the picked model", async () => {
235
237
  const store = new ReviewStore(freshStoreFile());
238
+ const { ctx, custom } = fakeCtx({ customResult: MODELS[2] });
236
239
  const picked = await pickReviewModel(ctx, store);
237
240
  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
- ]);
241
+ expect(custom).toHaveBeenCalledTimes(1);
243
242
  expect(store.getModel()).toBe("openrouter/openai/gpt-5");
244
243
  });
245
244
 
246
245
  it("returns undefined and does not persist when cancelled", async () => {
247
- const { ctx, select } = fakeCtx({ selectResult: undefined });
248
246
  const store = new ReviewStore(freshStoreFile());
247
+ const { ctx, custom } = fakeCtx({ customResult: undefined });
249
248
  expect(await pickReviewModel(ctx, store)).toBeUndefined();
250
- expect(select).toHaveBeenCalledTimes(1);
249
+ expect(custom).toHaveBeenCalledTimes(1);
251
250
  expect(store.getModel()).toBeUndefined();
252
251
  });
253
252
 
254
253
  it("fails gracefully when no models are available", async () => {
255
- const { ctx, notify, select } = fakeCtx({ models: [] });
254
+ const { ctx, notify, custom } = fakeCtx({ models: [] });
256
255
  const store = new ReviewStore(freshStoreFile());
257
256
  expect(await pickReviewModel(ctx, store)).toBeUndefined();
258
- expect(select).not.toHaveBeenCalled();
257
+ expect(custom).not.toHaveBeenCalled();
259
258
  expect(notify).toHaveBeenCalledWith(
260
259
  expect.stringContaining("没有可用模型"),
261
260
  "error",
@@ -263,52 +262,56 @@ describe("pickReviewModel", () => {
263
262
  });
264
263
 
265
264
  it("refuses when no dialog-capable UI is available", async () => {
266
- const { ctx, notify, select } = fakeCtx({ hasUI: false });
265
+ const { ctx, notify, custom } = fakeCtx({ hasUI: false });
267
266
  const store = new ReviewStore(freshStoreFile());
268
267
  expect(await pickReviewModel(ctx, store)).toBeUndefined();
269
- expect(select).not.toHaveBeenCalled();
268
+ expect(custom).not.toHaveBeenCalled();
270
269
  expect(notify).toHaveBeenCalledWith(
271
270
  expect.stringContaining("不支持模型选择对话框"),
272
271
  "error",
273
272
  );
274
273
  });
275
274
 
276
- it("works in rpc mode (dialogs are available there)", async () => {
277
- const { ctx, select } = fakeCtx({
275
+ it("works in rpc mode via the host selector", async () => {
276
+ const store = new ReviewStore(freshStoreFile());
277
+ const { ctx, custom, select } = fakeCtx({
278
278
  mode: "rpc",
279
279
  selectResult: "Claude Opus 4 (anthropic/claude-opus-4)",
280
280
  });
281
- const store = new ReviewStore(freshStoreFile());
282
281
  const picked = await pickReviewModel(ctx, store);
283
282
  expect(picked).toBe(MODELS[1]);
283
+ // rpc cannot host custom components — the host renders select instead.
284
+ expect(custom).not.toHaveBeenCalled();
284
285
  expect(select).toHaveBeenCalledTimes(1);
285
286
  expect(store.getModel()).toBe("anthropic/claude-opus-4");
286
287
  });
287
288
 
288
- it("puts the configured model first (Enter accepts it) and keeps config on cancel", async () => {
289
+ it("keeps the config untouched when cancelled", async () => {
289
290
  const store = new ReviewStore(freshStoreFile());
290
291
  store.setModel("anthropic/claude-opus-4");
291
- const { ctx, select } = fakeCtx({ selectResult: undefined });
292
+ const { ctx, custom } = fakeCtx({ customResult: undefined });
292
293
  await pickReviewModel(ctx, store);
293
- expect(select).toHaveBeenCalledWith(
294
- "选择评审模型 (当前: anthropic/claude-opus-4,回车使用)",
295
- [
296
- "Claude Opus 4 (anthropic/claude-opus-4)",
297
- "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
298
- "GPT-5 (openrouter/openai/gpt-5)",
299
- ],
300
- );
294
+ expect(custom).toHaveBeenCalledTimes(1);
301
295
  // Cancelling a change keeps the existing config.
302
296
  expect(store.getModel()).toBe("anthropic/claude-opus-4");
303
297
  });
304
298
 
299
+ it("warns when the configured model is unavailable", async () => {
300
+ const store = new ReviewStore(freshStoreFile());
301
+ store.setModel("anthropic/claude-opus-4-5"); // deleted in pi
302
+ const { ctx, notify } = fakeCtx({ customResult: undefined });
303
+ await pickReviewModel(ctx, store);
304
+ expect(notify).toHaveBeenCalledWith(
305
+ expect.stringContaining("不存在或不可用"),
306
+ "warning",
307
+ );
308
+ });
309
+
305
310
  it("re-picking the configured model does not rewrite the store", async () => {
306
311
  const store = new ReviewStore(freshStoreFile());
307
312
  store.setModel("anthropic/claude-opus-4");
308
313
  const spy = vi.spyOn(store, "setModel");
309
- const { ctx } = fakeCtx({
310
- selectResult: "Claude Opus 4 (anthropic/claude-opus-4)",
311
- });
314
+ const { ctx } = fakeCtx({ customResult: MODELS[1] });
312
315
  const picked = await pickReviewModel(ctx, store);
313
316
  expect(picked).toBe(MODELS[1]);
314
317
  expect(spy).not.toHaveBeenCalled();
@@ -317,9 +320,7 @@ describe("pickReviewModel", () => {
317
320
  it("persists and confirms when a different model is picked", async () => {
318
321
  const store = new ReviewStore(freshStoreFile());
319
322
  store.setModel("anthropic/claude-opus-4");
320
- const { ctx, notify } = fakeCtx({
321
- selectResult: "GPT-5 (openrouter/openai/gpt-5)",
322
- });
323
+ const { ctx, notify } = fakeCtx({ customResult: MODELS[2] });
323
324
  const picked = await pickReviewModel(ctx, store);
324
325
  expect(picked).toBe(MODELS[2]);
325
326
  expect(store.getModel()).toBe("openrouter/openai/gpt-5");
@@ -348,12 +349,10 @@ describe("createReviewRunner", () => {
348
349
  it("shows the picker and runs on the configured model when accepted", async () => {
349
350
  getReviewStore().setModel("anthropic/claude-sonnet-4");
350
351
  const { pi, setModel, sendUserMessage } = fakePi();
351
- const { ctx, notify, select } = fakeCtx({
352
- selectResult: "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
353
- });
352
+ const { ctx, notify, custom } = fakeCtx({ customResult: MODELS[0] });
354
353
  await createReviewRunner(pi)(ctx);
355
354
  // The picker is always shown, even with a configured model.
356
- expect(select).toHaveBeenCalledTimes(1);
355
+ expect(custom).toHaveBeenCalledTimes(1);
357
356
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
358
357
  expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
359
358
  expect(notify).toHaveBeenCalledWith(
@@ -365,35 +364,27 @@ describe("createReviewRunner", () => {
365
364
  it("runs with the configured model without dialogs when the UI cannot pick", async () => {
366
365
  getReviewStore().setModel("anthropic/claude-sonnet-4");
367
366
  const { pi, setModel, sendUserMessage } = fakePi();
368
- const { ctx, select } = fakeCtx({ hasUI: false });
367
+ const { ctx, custom } = fakeCtx({ hasUI: false });
369
368
  await createReviewRunner(pi)(ctx);
370
- expect(select).not.toHaveBeenCalled();
369
+ expect(custom).not.toHaveBeenCalled();
371
370
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
372
371
  expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
373
372
  });
374
373
 
375
374
  it("picks, persists and runs when no model is configured", async () => {
376
375
  const { pi, setModel, sendUserMessage } = fakePi();
377
- const { ctx, notify, select } = fakeCtx({
378
- selectResult: "GPT-5 (openrouter/openai/gpt-5)",
379
- });
376
+ const { ctx, custom } = fakeCtx({ customResult: MODELS[2] });
380
377
  await createReviewRunner(pi)(ctx);
381
378
  expect(getReviewStore().getModel()).toBe("openrouter/openai/gpt-5");
382
379
  expect(setModel).toHaveBeenCalledWith(MODELS[2]);
383
380
  expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
384
- expect(select).toHaveBeenCalledTimes(1);
385
- expect(notify).toHaveBeenCalledWith(
386
- expect.stringContaining("尚未配置评审模型"),
387
- "warning",
388
- );
381
+ expect(custom).toHaveBeenCalledTimes(1);
389
382
  });
390
383
 
391
384
  it("re-picks, persists and runs when the configured model is unavailable", async () => {
392
385
  getReviewStore().setModel("anthropic/claude-opus-4-5"); // deleted in pi
393
386
  const { pi, setModel, sendUserMessage } = fakePi();
394
- const { ctx, notify, select } = fakeCtx({
395
- selectResult: "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
396
- });
387
+ const { ctx, notify, custom } = fakeCtx({ customResult: MODELS[0] });
397
388
  await createReviewRunner(pi)(ctx);
398
389
  expect(notify).toHaveBeenCalledWith(
399
390
  expect.stringContaining("不存在或不可用"),
@@ -402,20 +393,14 @@ describe("createReviewRunner", () => {
402
393
  expect(getReviewStore().getModel()).toBe("anthropic/claude-sonnet-4");
403
394
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
404
395
  expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
405
- expect(select).toHaveBeenCalledTimes(1);
406
- // The title marks the configured model as unavailable (Enter picks the
407
- // first available row, not the configured one).
408
- expect(select).toHaveBeenCalledWith(
409
- "选择评审模型 (当前: anthropic/claude-opus-4-5 不可用)",
410
- expect.any(Array),
411
- );
396
+ expect(custom).toHaveBeenCalledTimes(1);
412
397
  });
413
398
 
414
399
  it("aborts when the user cancels the model pick", async () => {
415
400
  const { pi, setModel, sendUserMessage } = fakePi();
416
- const { ctx, select } = fakeCtx({ selectResult: undefined });
401
+ const { ctx, custom } = fakeCtx({ customResult: undefined });
417
402
  await createReviewRunner(pi)(ctx);
418
- expect(select).toHaveBeenCalledTimes(1);
403
+ expect(custom).toHaveBeenCalledTimes(1);
419
404
  expect(setModel).not.toHaveBeenCalled();
420
405
  expect(sendUserMessage).not.toHaveBeenCalled();
421
406
  expect(getReviewStore().getModel()).toBeUndefined();
@@ -424,9 +409,7 @@ describe("createReviewRunner", () => {
424
409
  it("reports and aborts when switching to the review model fails", async () => {
425
410
  getReviewStore().setModel("anthropic/claude-sonnet-4");
426
411
  const { pi, setModel, sendUserMessage } = fakePi(false); // setModel → false
427
- const { ctx, notify } = fakeCtx({
428
- selectResult: "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
429
- });
412
+ const { ctx, notify } = fakeCtx({ customResult: MODELS[0] });
430
413
  await createReviewRunner(pi)(ctx);
431
414
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
432
415
  expect(sendUserMessage).not.toHaveBeenCalled();
@@ -438,10 +421,10 @@ describe("createReviewRunner", () => {
438
421
 
439
422
  it("serializes concurrent triggers (one review at a time)", async () => {
440
423
  const { pi } = fakePi();
441
- // First run blocks inside ui.select until we resolve it.
442
- let release: ((v: string | undefined) => void) | undefined;
443
- const select = vi.fn(
444
- () => new Promise<string | undefined>((resolve) => (release = resolve)),
424
+ // First run blocks inside the picker until we resolve it.
425
+ let release: ((v: AvailableModel | undefined) => void) | undefined;
426
+ const custom = vi.fn(
427
+ () => new Promise<AvailableModel | undefined>((resolve) => (release = resolve)),
445
428
  );
446
429
  const ctx = {
447
430
  mode: "tui",
@@ -450,7 +433,7 @@ describe("createReviewRunner", () => {
450
433
  modelRegistry: {
451
434
  getAvailable: () => MODELS,
452
435
  } as unknown as ModelRegistry,
453
- ui: { notify: vi.fn(), select },
436
+ ui: { notify: vi.fn(), custom },
454
437
  } as unknown as ExtensionContext;
455
438
 
456
439
  const runner = createReviewRunner(pi);
@@ -462,14 +445,14 @@ describe("createReviewRunner", () => {
462
445
  hasUI: true,
463
446
  isIdle: () => true,
464
447
  modelRegistry: { getAvailable: () => MODELS } as unknown as ModelRegistry,
465
- ui: { notify: notify2, select: vi.fn(async () => undefined) },
448
+ ui: { notify: notify2, custom: vi.fn(async () => undefined) },
466
449
  } as unknown as ExtensionContext;
467
450
  await runner(ctx2);
468
451
  expect(notify2).toHaveBeenCalledWith(
469
452
  expect.stringContaining("评审已在执行中"),
470
453
  "warning",
471
454
  );
472
- release?.("Claude Sonnet 4 (anthropic/claude-sonnet-4)");
455
+ release?.(MODELS[0]);
473
456
  await first;
474
457
  expect(pi.setModel).toHaveBeenCalledTimes(1);
475
458
  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
+ }