@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 +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/favorites/favorites.test.ts +18 -7
- package/src/features/review/index.ts +41 -69
- package/src/features/review/review.test.ts +60 -77
- 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.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,
|
|
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
|
},
|
|
@@ -23,7 +23,15 @@ import {
|
|
|
23
23
|
ModelSelectorComponent,
|
|
24
24
|
type Theme,
|
|
25
25
|
} from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import {
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 (
|
|
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) {
|
|
@@ -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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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("
|
|
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(
|
|
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(
|
|
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,
|
|
254
|
+
const { ctx, notify, custom } = fakeCtx({ models: [] });
|
|
256
255
|
const store = new ReviewStore(freshStoreFile());
|
|
257
256
|
expect(await pickReviewModel(ctx, store)).toBeUndefined();
|
|
258
|
-
expect(
|
|
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,
|
|
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(
|
|
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
|
|
277
|
-
const
|
|
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("
|
|
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,
|
|
292
|
+
const { ctx, custom } = fakeCtx({ customResult: undefined });
|
|
292
293
|
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
|
-
);
|
|
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,
|
|
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(
|
|
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,
|
|
367
|
+
const { ctx, custom } = fakeCtx({ hasUI: false });
|
|
369
368
|
await createReviewRunner(pi)(ctx);
|
|
370
|
-
expect(
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
401
|
+
const { ctx, custom } = fakeCtx({ customResult: undefined });
|
|
417
402
|
await createReviewRunner(pi)(ctx);
|
|
418
|
-
expect(
|
|
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
|
|
442
|
-
let release: ((v:
|
|
443
|
-
const
|
|
444
|
-
() => new Promise<
|
|
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(),
|
|
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,
|
|
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?.(
|
|
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
|
+
}
|