@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 +3 -3
- package/package.json +1 -1
- package/src/features/compact/compact.test.ts +38 -15
- package/src/features/compact/index.ts +23 -35
- package/src/features/review/index.ts +41 -69
- package/src/features/review/review.test.ts +53 -72
- package/src/lib/model-picker.test.ts +191 -0
- package/src/lib/model-picker.ts +101 -0
package/README.md
CHANGED
|
@@ -163,8 +163,8 @@ pi -e ./src/index.ts
|
|
|
163
163
|
|
|
164
164
|
**评审模型选择**(每次 `/third-review` 都会弹出选择界面):
|
|
165
165
|
|
|
166
|
-
- `/third-review` →
|
|
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.
|
|
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,
|
|
285
|
+
const { ctx, notify, custom } = fakeCtx({ hasUI: false });
|
|
283
286
|
const model = await pickCompactModel(ctx, getCompactStore());
|
|
284
287
|
expect(model).toBeUndefined();
|
|
285
|
-
expect(
|
|
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("
|
|
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()).
|
|
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({
|
|
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
|
|
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
|
|
253
|
-
* choice. Mirrors review's pickReviewModel
|
|
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
|
|
267
|
-
|
|
268
|
+
const configured = store.getModel();
|
|
269
|
+
const configuredModel = resolveConfiguredModel(ctx, configured);
|
|
270
|
+
if (!configuredModel && configured) {
|
|
268
271
|
ctx.ui.notify(
|
|
269
|
-
|
|
270
|
-
"
|
|
272
|
+
`compact-model: 配置的压缩模型 ${configured} 不存在或不可用,请重新选择。`,
|
|
273
|
+
"warning",
|
|
271
274
|
);
|
|
272
|
-
return undefined;
|
|
273
275
|
}
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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:
|
|
281
|
+
"compact-model: 已取消,压缩模型配置未变更。清除配置请编辑 <agent-dir>/pi-toolkits-compact.json(把 model 置为 null)。",
|
|
296
282
|
"info",
|
|
297
283
|
);
|
|
298
284
|
return undefined;
|
|
299
285
|
}
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
302
|
-
|
|
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:
|
|
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
|
|
321
|
+
// Config entry point: view and change the compact model.
|
|
334
322
|
pi.registerCommand("compact-model", {
|
|
335
323
|
description:
|
|
336
|
-
"Set
|
|
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 (
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
21
|
-
* models.json) the user is warned
|
|
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
|
|
43
|
-
* choice. Returns the picked model, or undefined when cancelled
|
|
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
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
154
|
-
// (one Enter accepts it); picking another persists it
|
|
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
|
|
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.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
"
|
|
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 (
|
|
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
|
|
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("
|
|
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(
|
|
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(
|
|
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,
|
|
252
|
+
const { ctx, notify, custom } = fakeCtx({ models: [] });
|
|
256
253
|
const store = new ReviewStore(freshStoreFile());
|
|
257
254
|
expect(await pickReviewModel(ctx, store)).toBeUndefined();
|
|
258
|
-
expect(
|
|
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,
|
|
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(
|
|
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
|
|
277
|
-
const
|
|
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("
|
|
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,
|
|
290
|
+
const { ctx, custom } = fakeCtx({ customResult: undefined });
|
|
292
291
|
await pickReviewModel(ctx, store);
|
|
293
|
-
expect(
|
|
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,
|
|
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(
|
|
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,
|
|
365
|
+
const { ctx, custom } = fakeCtx({ hasUI: false });
|
|
369
366
|
await createReviewRunner(pi)(ctx);
|
|
370
|
-
expect(
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
399
|
+
const { ctx, custom } = fakeCtx({ customResult: undefined });
|
|
417
400
|
await createReviewRunner(pi)(ctx);
|
|
418
|
-
expect(
|
|
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
|
|
442
|
-
let release: ((v:
|
|
443
|
-
const
|
|
444
|
-
() => new Promise<
|
|
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(),
|
|
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,
|
|
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?.(
|
|
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
|
+
}
|