@andares/pi-toolkits 0.4.1 → 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
@@ -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.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,
@@ -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
  },
@@ -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) {
@@ -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,52 +260,56 @@ 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("puts the configured model first (Enter accepts it) and keeps config on cancel", async () => {
287
+ it("keeps the config untouched when cancelled", async () => {
289
288
  const store = new ReviewStore(freshStoreFile());
290
289
  store.setModel("anthropic/claude-opus-4");
291
- const { ctx, select } = fakeCtx({ selectResult: undefined });
290
+ const { ctx, custom } = fakeCtx({ customResult: undefined });
292
291
  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
- );
292
+ expect(custom).toHaveBeenCalledTimes(1);
301
293
  // Cancelling a change keeps the existing config.
302
294
  expect(store.getModel()).toBe("anthropic/claude-opus-4");
303
295
  });
304
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
+
305
308
  it("re-picking the configured model does not rewrite the store", async () => {
306
309
  const store = new ReviewStore(freshStoreFile());
307
310
  store.setModel("anthropic/claude-opus-4");
308
311
  const spy = vi.spyOn(store, "setModel");
309
- const { ctx } = fakeCtx({
310
- selectResult: "Claude Opus 4 (anthropic/claude-opus-4)",
311
- });
312
+ const { ctx } = fakeCtx({ customResult: MODELS[1] });
312
313
  const picked = await pickReviewModel(ctx, store);
313
314
  expect(picked).toBe(MODELS[1]);
314
315
  expect(spy).not.toHaveBeenCalled();
@@ -317,9 +318,7 @@ describe("pickReviewModel", () => {
317
318
  it("persists and confirms when a different model is picked", async () => {
318
319
  const store = new ReviewStore(freshStoreFile());
319
320
  store.setModel("anthropic/claude-opus-4");
320
- const { ctx, notify } = fakeCtx({
321
- selectResult: "GPT-5 (openrouter/openai/gpt-5)",
322
- });
321
+ const { ctx, notify } = fakeCtx({ customResult: MODELS[2] });
323
322
  const picked = await pickReviewModel(ctx, store);
324
323
  expect(picked).toBe(MODELS[2]);
325
324
  expect(store.getModel()).toBe("openrouter/openai/gpt-5");
@@ -348,12 +347,10 @@ describe("createReviewRunner", () => {
348
347
  it("shows the picker and runs on the configured model when accepted", async () => {
349
348
  getReviewStore().setModel("anthropic/claude-sonnet-4");
350
349
  const { pi, setModel, sendUserMessage } = fakePi();
351
- const { ctx, notify, select } = fakeCtx({
352
- selectResult: "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
353
- });
350
+ const { ctx, notify, custom } = fakeCtx({ customResult: MODELS[0] });
354
351
  await createReviewRunner(pi)(ctx);
355
352
  // The picker is always shown, even with a configured model.
356
- expect(select).toHaveBeenCalledTimes(1);
353
+ expect(custom).toHaveBeenCalledTimes(1);
357
354
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
358
355
  expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
359
356
  expect(notify).toHaveBeenCalledWith(
@@ -365,35 +362,27 @@ describe("createReviewRunner", () => {
365
362
  it("runs with the configured model without dialogs when the UI cannot pick", async () => {
366
363
  getReviewStore().setModel("anthropic/claude-sonnet-4");
367
364
  const { pi, setModel, sendUserMessage } = fakePi();
368
- const { ctx, select } = fakeCtx({ hasUI: false });
365
+ const { ctx, custom } = fakeCtx({ hasUI: false });
369
366
  await createReviewRunner(pi)(ctx);
370
- expect(select).not.toHaveBeenCalled();
367
+ expect(custom).not.toHaveBeenCalled();
371
368
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
372
369
  expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
373
370
  });
374
371
 
375
372
  it("picks, persists and runs when no model is configured", async () => {
376
373
  const { pi, setModel, sendUserMessage } = fakePi();
377
- const { ctx, notify, select } = fakeCtx({
378
- selectResult: "GPT-5 (openrouter/openai/gpt-5)",
379
- });
374
+ const { ctx, custom } = fakeCtx({ customResult: MODELS[2] });
380
375
  await createReviewRunner(pi)(ctx);
381
376
  expect(getReviewStore().getModel()).toBe("openrouter/openai/gpt-5");
382
377
  expect(setModel).toHaveBeenCalledWith(MODELS[2]);
383
378
  expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
384
- expect(select).toHaveBeenCalledTimes(1);
385
- expect(notify).toHaveBeenCalledWith(
386
- expect.stringContaining("尚未配置评审模型"),
387
- "warning",
388
- );
379
+ expect(custom).toHaveBeenCalledTimes(1);
389
380
  });
390
381
 
391
382
  it("re-picks, persists and runs when the configured model is unavailable", async () => {
392
383
  getReviewStore().setModel("anthropic/claude-opus-4-5"); // deleted in pi
393
384
  const { pi, setModel, sendUserMessage } = fakePi();
394
- const { ctx, notify, select } = fakeCtx({
395
- selectResult: "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
396
- });
385
+ const { ctx, notify, custom } = fakeCtx({ customResult: MODELS[0] });
397
386
  await createReviewRunner(pi)(ctx);
398
387
  expect(notify).toHaveBeenCalledWith(
399
388
  expect.stringContaining("不存在或不可用"),
@@ -402,20 +391,14 @@ describe("createReviewRunner", () => {
402
391
  expect(getReviewStore().getModel()).toBe("anthropic/claude-sonnet-4");
403
392
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
404
393
  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
- );
394
+ expect(custom).toHaveBeenCalledTimes(1);
412
395
  });
413
396
 
414
397
  it("aborts when the user cancels the model pick", async () => {
415
398
  const { pi, setModel, sendUserMessage } = fakePi();
416
- const { ctx, select } = fakeCtx({ selectResult: undefined });
399
+ const { ctx, custom } = fakeCtx({ customResult: undefined });
417
400
  await createReviewRunner(pi)(ctx);
418
- expect(select).toHaveBeenCalledTimes(1);
401
+ expect(custom).toHaveBeenCalledTimes(1);
419
402
  expect(setModel).not.toHaveBeenCalled();
420
403
  expect(sendUserMessage).not.toHaveBeenCalled();
421
404
  expect(getReviewStore().getModel()).toBeUndefined();
@@ -424,9 +407,7 @@ describe("createReviewRunner", () => {
424
407
  it("reports and aborts when switching to the review model fails", async () => {
425
408
  getReviewStore().setModel("anthropic/claude-sonnet-4");
426
409
  const { pi, setModel, sendUserMessage } = fakePi(false); // setModel → false
427
- const { ctx, notify } = fakeCtx({
428
- selectResult: "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
429
- });
410
+ const { ctx, notify } = fakeCtx({ customResult: MODELS[0] });
430
411
  await createReviewRunner(pi)(ctx);
431
412
  expect(setModel).toHaveBeenCalledWith(MODELS[0]);
432
413
  expect(sendUserMessage).not.toHaveBeenCalled();
@@ -438,10 +419,10 @@ describe("createReviewRunner", () => {
438
419
 
439
420
  it("serializes concurrent triggers (one review at a time)", async () => {
440
421
  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)),
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)),
445
426
  );
446
427
  const ctx = {
447
428
  mode: "tui",
@@ -450,7 +431,7 @@ describe("createReviewRunner", () => {
450
431
  modelRegistry: {
451
432
  getAvailable: () => MODELS,
452
433
  } as unknown as ModelRegistry,
453
- ui: { notify: vi.fn(), select },
434
+ ui: { notify: vi.fn(), custom },
454
435
  } as unknown as ExtensionContext;
455
436
 
456
437
  const runner = createReviewRunner(pi);
@@ -462,14 +443,14 @@ describe("createReviewRunner", () => {
462
443
  hasUI: true,
463
444
  isIdle: () => true,
464
445
  modelRegistry: { getAvailable: () => MODELS } as unknown as ModelRegistry,
465
- ui: { notify: notify2, select: vi.fn(async () => undefined) },
446
+ ui: { notify: notify2, custom: vi.fn(async () => undefined) },
466
447
  } as unknown as ExtensionContext;
467
448
  await runner(ctx2);
468
449
  expect(notify2).toHaveBeenCalledWith(
469
450
  expect.stringContaining("评审已在执行中"),
470
451
  "warning",
471
452
  );
472
- release?.("Claude Sonnet 4 (anthropic/claude-sonnet-4)");
453
+ release?.(MODELS[0]);
473
454
  await first;
474
455
  expect(pi.setModel).toHaveBeenCalledTimes(1);
475
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
+ }