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