@andares/pi-toolkits 0.4.0 → 0.4.1
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 +9 -43
- package/package.json +1 -1
- package/src/features/compact/index.ts +1 -5
- 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 +57 -15
- package/src/features/review/review.test.ts +61 -8
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
|
|
|
@@ -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.1",
|
|
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",
|
|
@@ -107,11 +107,7 @@ async function completeSummary(
|
|
|
107
107
|
);
|
|
108
108
|
}
|
|
109
109
|
const text = response.content
|
|
110
|
-
.
|
|
111
|
-
(block): block is { type: "text"; text: string } =>
|
|
112
|
-
block.type === "text",
|
|
113
|
-
)
|
|
114
|
-
.map((block) => block.text)
|
|
110
|
+
.flatMap((block) => (block.type === "text" ? [block.text] : []))
|
|
115
111
|
.join("\n");
|
|
116
112
|
return { text, usage: response.usage };
|
|
117
113
|
}
|
|
@@ -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,14 @@
|
|
|
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 picker (ctx.ui.select, same
|
|
16
|
+
* getAvailable() source as /model) with the configured model as the FIRST
|
|
17
|
+
* row — one Enter accepts it and starts the review. Picking a different
|
|
18
|
+
* model persists it immediately (disk + in-memory) as the new default for
|
|
19
|
+
* next time; cancelling aborts the review without changing anything. When
|
|
20
|
+
* the configured model is gone/unavailable (e.g. deleted from pi's
|
|
21
|
+
* models.json) the user is warned and the picker opens on the plain list.
|
|
19
22
|
*
|
|
20
23
|
* Guards: never fires while the agent is streaming (the review needs the
|
|
21
24
|
* just-finished work as context) and never runs two reviews concurrently.
|
|
@@ -39,8 +42,11 @@ import { getReviewStore, type ReviewStore } from "./store.js";
|
|
|
39
42
|
* Let the user pick a review model from the available list and persist the
|
|
40
43
|
* choice. Returns the picked model, or undefined when cancelled/unavailable.
|
|
41
44
|
*
|
|
42
|
-
* The
|
|
43
|
-
*
|
|
45
|
+
* The configured model is placed FIRST: the select dialog always highlights
|
|
46
|
+
* its first row, so one Enter accepts the current default. Picking any other
|
|
47
|
+
* model persists it (disk + in-memory) as the new default. The title shows
|
|
48
|
+
* the current config, so /review-model doubles as a view-and-change entry
|
|
49
|
+
* point; cancelling leaves the config untouched.
|
|
44
50
|
*/
|
|
45
51
|
export async function pickReviewModel(
|
|
46
52
|
ctx: ExtensionContext,
|
|
@@ -64,21 +70,45 @@ export async function pickReviewModel(
|
|
|
64
70
|
return undefined;
|
|
65
71
|
}
|
|
66
72
|
const configured = store.getModel();
|
|
73
|
+
const configuredModel = resolveConfiguredModel(ctx, configured);
|
|
74
|
+
// Configured model first → highlighted by default (Enter accepts it);
|
|
75
|
+
// the rest keep the registry order.
|
|
76
|
+
const ordered: AvailableModel[] = configuredModel
|
|
77
|
+
? [
|
|
78
|
+
configuredModel,
|
|
79
|
+
...available.filter(
|
|
80
|
+
(m) => modelKey(m) !== modelKey(configuredModel),
|
|
81
|
+
),
|
|
82
|
+
]
|
|
83
|
+
: available;
|
|
67
84
|
// Same display source as the /model selector: name + [provider]/id.
|
|
68
|
-
const options =
|
|
69
|
-
|
|
70
|
-
configured
|
|
71
|
-
? `选择评审模型 (当前: ${configured})`
|
|
72
|
-
: "选择评审模型 (third-review)",
|
|
73
|
-
options,
|
|
85
|
+
const options = ordered.map(
|
|
86
|
+
(m: AvailableModel) => `${m.name} (${m.provider}/${m.id})`,
|
|
74
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} 不可用)`;
|
|
95
|
+
}
|
|
96
|
+
const choice = await ctx.ui.select(title, options);
|
|
75
97
|
if (choice === undefined) {
|
|
76
98
|
ctx.ui.notify("third-review: 已取消,评审模型配置未变更。", "info");
|
|
77
99
|
return undefined;
|
|
78
100
|
}
|
|
79
|
-
const model =
|
|
101
|
+
const model = ordered[options.indexOf(choice)];
|
|
80
102
|
if (!model) return undefined;
|
|
81
|
-
|
|
103
|
+
const key = modelKey(model);
|
|
104
|
+
if (key !== configured) {
|
|
105
|
+
// Persist immediately (disk + in-memory + footer) — next run defaults here.
|
|
106
|
+
store.setModel(key);
|
|
107
|
+
ctx.ui.notify(
|
|
108
|
+
`third-review: 评审模型已保存为「${model.name}」,下次默认使用。`,
|
|
109
|
+
"info",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
82
112
|
return model;
|
|
83
113
|
}
|
|
84
114
|
|
|
@@ -118,8 +148,20 @@ export function createReviewRunner(
|
|
|
118
148
|
: "third-review: 尚未配置评审模型,请选择。",
|
|
119
149
|
"warning",
|
|
120
150
|
);
|
|
151
|
+
}
|
|
152
|
+
if (ctx.hasUI) {
|
|
153
|
+
// Always show the picker: the configured model is the first row
|
|
154
|
+
// (one Enter accepts it); picking another persists it as default.
|
|
121
155
|
model = await pickReviewModel(ctx, store);
|
|
122
156
|
if (!model) return;
|
|
157
|
+
} else if (!model) {
|
|
158
|
+
// No dialogs (json/print/headless): the configured-model path keeps
|
|
159
|
+
// working; without one, point at the config file.
|
|
160
|
+
ctx.ui.notify(
|
|
161
|
+
"third-review: 当前环境不支持模型选择对话框,请编辑 <agent-dir>/pi-toolkits-review.json 手动配置。",
|
|
162
|
+
"error",
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
123
165
|
}
|
|
124
166
|
const switched = await pi.setModel(model);
|
|
125
167
|
if (!switched) {
|
|
@@ -285,17 +285,48 @@ describe("pickReviewModel", () => {
|
|
|
285
285
|
expect(store.getModel()).toBe("anthropic/claude-opus-4");
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
-
it("
|
|
288
|
+
it("puts the configured model first (Enter accepts it) and keeps config on cancel", async () => {
|
|
289
289
|
const store = new ReviewStore(freshStoreFile());
|
|
290
|
-
store.setModel("anthropic/claude-
|
|
290
|
+
store.setModel("anthropic/claude-opus-4");
|
|
291
291
|
const { ctx, select } = fakeCtx({ selectResult: undefined });
|
|
292
292
|
await pickReviewModel(ctx, store);
|
|
293
293
|
expect(select).toHaveBeenCalledWith(
|
|
294
|
-
"选择评审模型 (当前: anthropic/claude-
|
|
295
|
-
|
|
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
|
+
],
|
|
296
300
|
);
|
|
297
301
|
// Cancelling a change keeps the existing config.
|
|
298
|
-
expect(store.getModel()).toBe("anthropic/claude-
|
|
302
|
+
expect(store.getModel()).toBe("anthropic/claude-opus-4");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("re-picking the configured model does not rewrite the store", async () => {
|
|
306
|
+
const store = new ReviewStore(freshStoreFile());
|
|
307
|
+
store.setModel("anthropic/claude-opus-4");
|
|
308
|
+
const spy = vi.spyOn(store, "setModel");
|
|
309
|
+
const { ctx } = fakeCtx({
|
|
310
|
+
selectResult: "Claude Opus 4 (anthropic/claude-opus-4)",
|
|
311
|
+
});
|
|
312
|
+
const picked = await pickReviewModel(ctx, store);
|
|
313
|
+
expect(picked).toBe(MODELS[1]);
|
|
314
|
+
expect(spy).not.toHaveBeenCalled();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("persists and confirms when a different model is picked", async () => {
|
|
318
|
+
const store = new ReviewStore(freshStoreFile());
|
|
319
|
+
store.setModel("anthropic/claude-opus-4");
|
|
320
|
+
const { ctx, notify } = fakeCtx({
|
|
321
|
+
selectResult: "GPT-5 (openrouter/openai/gpt-5)",
|
|
322
|
+
});
|
|
323
|
+
const picked = await pickReviewModel(ctx, store);
|
|
324
|
+
expect(picked).toBe(MODELS[2]);
|
|
325
|
+
expect(store.getModel()).toBe("openrouter/openai/gpt-5");
|
|
326
|
+
expect(notify).toHaveBeenCalledWith(
|
|
327
|
+
expect.stringContaining("已保存为"),
|
|
328
|
+
"info",
|
|
329
|
+
);
|
|
299
330
|
});
|
|
300
331
|
});
|
|
301
332
|
|
|
@@ -314,11 +345,15 @@ describe("createReviewRunner", () => {
|
|
|
314
345
|
expect(pi.sendUserMessage).not.toHaveBeenCalled();
|
|
315
346
|
});
|
|
316
347
|
|
|
317
|
-
it("
|
|
348
|
+
it("shows the picker and runs on the configured model when accepted", async () => {
|
|
318
349
|
getReviewStore().setModel("anthropic/claude-sonnet-4");
|
|
319
350
|
const { pi, setModel, sendUserMessage } = fakePi();
|
|
320
|
-
const { ctx, notify } = fakeCtx(
|
|
351
|
+
const { ctx, notify, select } = fakeCtx({
|
|
352
|
+
selectResult: "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
|
|
353
|
+
});
|
|
321
354
|
await createReviewRunner(pi)(ctx);
|
|
355
|
+
// The picker is always shown, even with a configured model.
|
|
356
|
+
expect(select).toHaveBeenCalledTimes(1);
|
|
322
357
|
expect(setModel).toHaveBeenCalledWith(MODELS[0]);
|
|
323
358
|
expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
|
|
324
359
|
expect(notify).toHaveBeenCalledWith(
|
|
@@ -327,6 +362,16 @@ describe("createReviewRunner", () => {
|
|
|
327
362
|
);
|
|
328
363
|
});
|
|
329
364
|
|
|
365
|
+
it("runs with the configured model without dialogs when the UI cannot pick", async () => {
|
|
366
|
+
getReviewStore().setModel("anthropic/claude-sonnet-4");
|
|
367
|
+
const { pi, setModel, sendUserMessage } = fakePi();
|
|
368
|
+
const { ctx, select } = fakeCtx({ hasUI: false });
|
|
369
|
+
await createReviewRunner(pi)(ctx);
|
|
370
|
+
expect(select).not.toHaveBeenCalled();
|
|
371
|
+
expect(setModel).toHaveBeenCalledWith(MODELS[0]);
|
|
372
|
+
expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
|
|
373
|
+
});
|
|
374
|
+
|
|
330
375
|
it("picks, persists and runs when no model is configured", async () => {
|
|
331
376
|
const { pi, setModel, sendUserMessage } = fakePi();
|
|
332
377
|
const { ctx, notify, select } = fakeCtx({
|
|
@@ -358,6 +403,12 @@ describe("createReviewRunner", () => {
|
|
|
358
403
|
expect(setModel).toHaveBeenCalledWith(MODELS[0]);
|
|
359
404
|
expect(sendUserMessage).toHaveBeenCalledWith(REVIEW_PROMPT);
|
|
360
405
|
expect(select).toHaveBeenCalledTimes(1);
|
|
406
|
+
// The title marks the configured model as unavailable (Enter picks the
|
|
407
|
+
// first available row, not the configured one).
|
|
408
|
+
expect(select).toHaveBeenCalledWith(
|
|
409
|
+
"选择评审模型 (当前: anthropic/claude-opus-4-5 不可用)",
|
|
410
|
+
expect.any(Array),
|
|
411
|
+
);
|
|
361
412
|
});
|
|
362
413
|
|
|
363
414
|
it("aborts when the user cancels the model pick", async () => {
|
|
@@ -373,7 +424,9 @@ describe("createReviewRunner", () => {
|
|
|
373
424
|
it("reports and aborts when switching to the review model fails", async () => {
|
|
374
425
|
getReviewStore().setModel("anthropic/claude-sonnet-4");
|
|
375
426
|
const { pi, setModel, sendUserMessage } = fakePi(false); // setModel → false
|
|
376
|
-
const { ctx, notify } = fakeCtx(
|
|
427
|
+
const { ctx, notify } = fakeCtx({
|
|
428
|
+
selectResult: "Claude Sonnet 4 (anthropic/claude-sonnet-4)",
|
|
429
|
+
});
|
|
377
430
|
await createReviewRunner(pi)(ctx);
|
|
378
431
|
expect(setModel).toHaveBeenCalledWith(MODELS[0]);
|
|
379
432
|
expect(sendUserMessage).not.toHaveBeenCalled();
|