@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 CHANGED
@@ -161,14 +161,19 @@ pi -e ./src/index.ts
161
161
 
162
162
  **范围强约束** —— 评审仅限**当前本轮修改内容**,避免扩大范围;本轮改动范围内确认的问题才直接修复,范围外的历史遗留/无关问题只记录提示、不动手改。
163
163
 
164
- **评审模型配置**(`<agent-dir>/pi-toolkits-review.json`,默认 `~/.pi/agent/`):
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 里删掉了该模型)→ 提示「评审模型不存在」,弹出**可用模型选择列表**(与 `/model` 同源);选中后**写回配置并立即执行**
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` 之一;上级递增清零下级。执行链:校验 → `typecheck + test` 门禁 → 改版本 → git commit + `vX.Y.Z` tag → `pnpm publish`(`prepublishOnly` 二次门禁)。
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.0",
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
- .filter(
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
- .filter(
151
- (sm) => availableIds.has(`${sm.model.provider}\0${sm.model.id}`),
152
- )
153
- .map((sm) => ({
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 modelText = isFavorite
195
- ? styleFavorite(item.id)
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). When no model is configured, or the
15
- * configured model is gone/unavailable (e.g. deleted from pi's models.json),
16
- * the user is told the review model doesn't exist and is shown the available
17
- * model list (ctx.ui.select, same getAvailable() source as /model); the pick
18
- * is written back to the config and the review runs immediately.
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 picker title shows the current config so /review-model doubles as a
43
- * view-and-change entry point; cancelling leaves the config untouched.
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 = available.map((m: AvailableModel) => `${m.name} (${m.provider}/${m.id})`);
69
- const choice = await ctx.ui.select(
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 = available[options.indexOf(choice)];
101
+ const model = ordered[options.indexOf(choice)];
80
102
  if (!model) return undefined;
81
- store.setModel(modelKey(model));
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("shows the current config in the picker title", async () => {
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-sonnet-4");
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-sonnet-4)",
295
- expect.any(Array),
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-sonnet-4");
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("switches to the configured model and sends the review prompt", async () => {
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();