@unifan/pi-review-zh 1.0.11 → 1.0.12

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.
Files changed (2) hide show
  1. package/index.ts +971 -238
  2. package/package.json +2 -2
package/index.ts CHANGED
@@ -1,288 +1,1021 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
-
4
- import { parseReviewArgs } from "./src/cli-args.js";
5
- import {
6
- DEFAULT_CONFIG,
7
- configPath,
8
- loadConfig,
9
- mergeWithDefaults,
10
- resolveModel,
11
- validateConfig,
12
- writeConfig,
13
- } from "./src/config.js";
14
- import { prepareRun } from "./src/review-run.js";
15
- import { registerReviewReportTool } from "./src/tool-wrapper.js";
16
- import { registerPiReviewRenderer } from "./src/tui-renderer.js";
17
-
18
- function parentModelId(ctx: ExtensionCommandContext): string | undefined {
19
- const m = ctx.model;
20
- if (!m) return undefined;
21
- return `${m.provider}/${m.id}`;
1
+ /**
2
+ * Pi 交互式 AI 代码审查扩展 (中文增强版)
3
+ *
4
+ * 参考并融合了 Codex pi-agent-extensions (Armin Ronacher @mitsuhiko) 的经典架构:
5
+ * 采用原生会话与会话分支隔离树技术,彻底摒弃脆弱的多子代理并发通信链路。
6
+ * 零网络断流、零子进程超时、支持任意大模型(GPT/Claude/DeepSeek/免费模型)。
7
+ *
8
+ * 支持审查模式:
9
+ * - 审查当前未提交的改动 (工作区 + 暂存区)
10
+ * - 审查 GitHub PR (自动通过 gh 本地检出 PR 分支)
11
+ * - 与基准分支 (如 main / master / dev) 进行差分审查
12
+ * - 审查指定的历史 Commit 提交
13
+ * - 审查指定目录或文件快照
14
+ * - 自定义审查要求与重点说明
15
+ *
16
+ * 会话分支隔离:
17
+ * - 默认支持在新分支 (Empty branch) 中开启审查,保持主会话干净
18
+ * - 审查过程中常驻黄色横幅提醒,完成后敲 /end-review
19
+ * - /end-review 自动将审查发现 (P0~P3) 结构化汇总并一键跳回原会话位置,自动填入修复指令
20
+ */
21
+
22
+ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
23
+ import { DynamicBorder, BorderedLoader } from "@earendil-works/pi-coding-agent";
24
+ import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
25
+ import path from "node:path";
26
+ import { promises as fs } from "node:fs";
27
+
28
+ // 跟踪审查会话来源分支节点(保证单次仅一个活跃审查会话)
29
+ let reviewOriginId: string | undefined = undefined;
30
+
31
+ const REVIEW_STATE_TYPE = "review-session";
32
+
33
+ type ReviewSessionState = {
34
+ active: boolean;
35
+ originId?: string;
36
+ };
37
+
38
+ function setReviewWidget(ctx: ExtensionContext, active: boolean) {
39
+ if (!ctx.hasUI) return;
40
+ if (!active) {
41
+ ctx.ui.setWidget("review", undefined);
42
+ return;
43
+ }
44
+
45
+ ctx.ui.setWidget("review", (_tui, theme) => {
46
+ const text = new Text(theme.fg("warning", "🔍 代码审查分支进行中,审查完毕后输入 /end-review 返回主对话"), 0, 0);
47
+ return {
48
+ render(width: number) {
49
+ return text.render(width);
50
+ },
51
+ invalidate() {
52
+ text.invalidate();
53
+ },
54
+ };
55
+ });
22
56
  }
23
57
 
24
- export default function (pi: ExtensionAPI) {
25
- registerReviewReportTool(pi);
26
- registerPiReviewRenderer(pi);
27
- pi.registerCommand("review", {
28
- description: "启动日常 AI 代码审查 (3 大核心专家 + 门禁裁判长)。--lite 极速单兵,--perf 性能审查,--full 全量会诊。",
29
- getArgumentCompletions: (prefix: string) => {
30
- const options = [
31
- { value: "--lite", label: "--lite", description: "极速单专家审查 (无门禁,低延迟极省 Token)" },
32
- { value: "--perf", label: "--perf", description: "专项性能与基准测试审查 (GC/内存分配/CPU/Benchmark)" },
33
- { value: "--full", label: "--full", description: "全量 6 专家深度会诊 (Bugbot/安全/合规/历史/注释/性能 + 门禁)" },
34
- { value: "--gate-model", label: "--gate-model", description: "指定当前审查的门禁裁判模型" },
35
- ];
36
- const trimmed = prefix.trimStart();
37
- if (!trimmed) return options;
38
- const tokens = trimmed.split(/\s+/).filter(Boolean);
39
- const last = tokens[tokens.length - 1] ?? "";
40
- if (last.startsWith("--")) {
41
- return options.filter((o) => o.value.startsWith(last));
58
+ function getReviewState(ctx: ExtensionContext): ReviewSessionState | undefined {
59
+ let state: ReviewSessionState | undefined;
60
+ for (const entry of ctx.sessionManager.getBranch()) {
61
+ if (entry.type === "custom" && entry.customType === REVIEW_STATE_TYPE) {
62
+ state = entry.data as ReviewSessionState | undefined;
63
+ }
64
+ }
65
+ return state;
66
+ }
67
+
68
+ function applyReviewState(ctx: ExtensionContext) {
69
+ const state = getReviewState(ctx);
70
+ if (state?.active && state.originId) {
71
+ reviewOriginId = state.originId;
72
+ setReviewWidget(ctx, true);
73
+ return;
74
+ }
75
+
76
+ reviewOriginId = undefined;
77
+ setReviewWidget(ctx, false);
78
+ }
79
+
80
+ // 审查目标类型
81
+ type ReviewTarget =
82
+ | { type: "uncommitted" }
83
+ | { type: "baseBranch"; branch: string }
84
+ | { type: "commit"; sha: string; title?: string }
85
+ | { type: "custom"; instructions: string }
86
+ | { type: "pullRequest"; prNumber: number; baseBranch: string; title: string }
87
+ | { type: "folder"; paths: string[] };
88
+
89
+ // 针对不同审查目标的中文提示词
90
+ const UNCOMMITTED_PROMPT =
91
+ "请审查当前代码的所有改动(包含暂存区、未暂存区以及新增文件)。请直接运行 `git diff`、`git status` 及必要的文件读取工具深入审查,并给出按优先级排序的具体审查发现。所有输出必须使用纯正中文。";
92
+
93
+ const BASE_BRANCH_PROMPT_WITH_MERGE_BASE =
94
+ "请审查当前分支相对于基准分支 '{baseBranch}' 的改动代码。两者的合并基准 commit 为 {mergeBaseSha}。请直接运行 `git diff {mergeBaseSha}` 检查本次改动并给出按优先级排序的具体审查发现。所有输出必须使用纯正中文。";
95
+
96
+ const BASE_BRANCH_PROMPT_FALLBACK =
97
+ "请审查当前分支相对于基准分支 '{branch}' 的改动代码。首先使用 `git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{upstream}\")\"` 找到合并基准,再通过 `git diff` 检查改动并给出具体审查发现。所有输出必须使用纯正中文。";
98
+
99
+ const COMMIT_PROMPT_WITH_TITLE =
100
+ '请审查提交 commit {sha} ("{title}") 引入的代码改动。直接运行 `git show {sha}` 检查差异并给出按优先级排序的具体审查发现。所有输出必须使用纯正中文。';
101
+
102
+ const COMMIT_PROMPT =
103
+ "请审查提交 commit {sha} 引入的代码改动。直接运行 `git show {sha}` 检查差异并给出按优先级排序的具体审查发现。所有输出必须使用纯正中文。";
104
+
105
+ const PULL_REQUEST_PROMPT =
106
+ '请审查 Pull Request #{prNumber} ("{title}") 相对于基准分支 \'{baseBranch}\' 的改动代码。两者的合并基准 commit 为 {mergeBaseSha}。请直接运行 `git diff {mergeBaseSha}` 检查改动并给出按优先级排序的具体审查发现。所有输出必须使用纯正中文。';
107
+
108
+ const PULL_REQUEST_PROMPT_FALLBACK =
109
+ '请审查 Pull Request #{prNumber} ("{title}") 相对于基准分支 \'{baseBranch}\' 的改动代码。首先寻找当前分支与 {baseBranch} 的合并基准 (例如 `git merge-base HEAD {baseBranch}`),再通过 `git diff` 检查改动并给出具体审查发现。所有输出必须使用纯正中文。';
110
+
111
+ const FOLDER_REVIEW_PROMPT =
112
+ "请对以下目录/文件路径的代码进行快照审查:{paths}。注意这是全量快照审查(非 diff 对比)。请直接读取这些文件并给出按优先级排序的具体审查发现。所有输出必须使用纯正中文。";
113
+
114
+ // 权威的中文代码审查准则 (基于 Codex 准则精炼与本土化)
115
+ const REVIEW_RUBRIC = `# 核心代码审查准则(资深工程师视角)
116
+
117
+ 你正在作为一名资深技术专家对另一位工程师提交的代码改动进行严格的代码审查。你的目标是帮作者把关并拦截真实风险,给出清晰、可落地、带事实证据的中文审查意见。
118
+
119
+ ## 重点排查范围(排查什么)
120
+ 1. **代码正确性与边界处理**:逻辑缺陷、空指针/未定义引用、边界越界、生命周期异常、未捕获的运行时异常。
121
+ 2. **并发与状态安全**:竞态条件、死锁隐患、异步缺少等待、未处理的取消或中断、脏状态残留。
122
+ 3. **性能与内存开销**:高频主循环内的大量内存分配 (GC 压力)、不必要的深拷贝、高复杂度算法、资源句柄或网络连接未释放。
123
+ 4. **代码健壮性与外部合规**:
124
+ - 严禁信任外部用户输入(必须检查未参数化的 SQL、路径穿越、未校验的 URL 重定向、危险的反序列化等)。
125
+ - 报错与异常必须检查稳定的错误码或类型,严禁用脆弱的错误文本字符串做业务分支判断。
126
+ 5. **本次改动引入的缺陷**:只审查本次改动实际引入的问题,严禁把改动前既有的历史代码或未改动代码归咎为缺陷。
127
+
128
+ ## 严格过滤误报(不排查什么)
129
+ - ❌ 严禁提出吹毛求疵、纯属个人审美的废话风格建议(若无明确项目规范强制要求)。
130
+ - ❌ 严禁提出 Linter、类型检查器、编译构建会自动捕获的浅层格式建议。
131
+ - ❌ 严禁基于未证实的纯主观假设进行无端猜测,每一条问题必须有明确的代码事实证据。
132
+
133
+ ## 缺陷严重等级标记
134
+ 每一条审查发现必须在标题中清晰标注严重等级:
135
+ - **[P0 - 致命阻塞]** 导致系统崩溃、死锁、数据损坏、关键功能完全不可用或高危漏洞,必须立即修复,阻断合并。
136
+ - **[P1 - 紧急待修]** 明确的逻辑缺陷、高概率边界异常、严重性能退化或破坏公共接口契约,应在本次合并前修复。
137
+ - **[P2 - 普通建议]** 局部的健壮性隐患、轻度可读性或次要设计问题,建议在后续优化。
138
+ - **[P3 - 细节优化]** 极轻微的细节优化建议,不影响业务。
139
+
140
+ ## 输出格式(所有内容必须使用纯正中文)
141
+ 请按以下规范输出审查报告:
142
+
143
+ ### 审查发现清单
144
+ 每个问题按以下格式列出(若完全无缺陷,请明确输出:\`未发现存活的代码缺陷,代码质量良好,建议合并。\`):
145
+ - **[P0|P1|P2|P3] 简短标题**:\`文件路径:行号\`
146
+ - **缺陷说明**:简明扼要说明该问题会导致什么后果以及在何种场景下被触发。
147
+ - **代码证据**:引用 1~3 行具体代码或调用链路。
148
+ - **修改建议**:给出最小化的修复思路或直接附带精准的代码替换块(可使用 \`\`\`suggestion 代码块)。
149
+
150
+ ### 综合裁决
151
+ - **最终结论**:\`通过 (Approved)\` 或 \`需要修改 (Request Changes - 存在 P0/P1 阻塞问题)\`
152
+ - **总结说明**:一句话中文总评。`;
153
+
154
+ /**
155
+ * 尝试加载项目本地的专属审查准则文件 (REVIEW_GUIDELINES.md 或 AGENTS.md / CLAUDE.md)
156
+ */
157
+ async function loadProjectReviewGuidelines(cwd: string): Promise<string | null> {
158
+ let currentDir = path.resolve(cwd);
159
+
160
+ while (true) {
161
+ const piDir = path.join(currentDir, ".pi");
162
+ const candidates = [
163
+ path.join(currentDir, "REVIEW_GUIDELINES.md"),
164
+ path.join(currentDir, "AGENTS.md"),
165
+ path.join(currentDir, "CLAUDE.md"),
166
+ ];
167
+
168
+ const piStats = await fs.stat(piDir).catch(() => null);
169
+ if (piStats?.isDirectory()) {
170
+ for (const guidelinePath of candidates) {
171
+ const stat = await fs.stat(guidelinePath).catch(() => null);
172
+ if (stat?.isFile()) {
173
+ try {
174
+ const content = await fs.readFile(guidelinePath, "utf8");
175
+ const trimmed = content.trim();
176
+ if (trimmed) return trimmed.slice(0, 3000);
177
+ } catch {
178
+ /* ignore */
179
+ }
180
+ }
42
181
  }
43
- return options;
44
- },
45
- handler: async (args, ctx) => {
46
- const notify = (msg: string, level: "info" | "warning" | "error" = "info") => {
47
- if (ctx.hasUI) ctx.ui.notify(msg, level);
48
- else console.log(`pi-review: ${msg}`);
182
+ return null;
183
+ }
184
+
185
+ const parentDir = path.dirname(currentDir);
186
+ if (parentDir === currentDir) return null;
187
+ currentDir = parentDir;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * 获取 HEAD 与指定分支的 merge base
193
+ */
194
+ async function getMergeBase(pi: ExtensionAPI, branch: string): Promise<string | null> {
195
+ try {
196
+ const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [
197
+ "rev-parse",
198
+ "--abbrev-ref",
199
+ `${branch}@{upstream}`,
200
+ ]);
201
+
202
+ if (upstreamCode === 0 && upstream.trim()) {
203
+ const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", upstream.trim()]);
204
+ if (code === 0 && mergeBase.trim()) return mergeBase.trim();
205
+ }
206
+
207
+ const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", branch]);
208
+ if (code === 0 && mergeBase.trim()) return mergeBase.trim();
209
+ return null;
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ async function getLocalBranches(pi: ExtensionAPI): Promise<string[]> {
216
+ const { stdout, code } = await pi.exec("git", ["branch", "--format=%(refname:short)"]);
217
+ if (code !== 0) return [];
218
+ return stdout
219
+ .trim()
220
+ .split("\n")
221
+ .map((b) => b.trim())
222
+ .filter(Boolean);
223
+ }
224
+
225
+ async function getRecentCommits(pi: ExtensionAPI, limit = 20): Promise<Array<{ sha: string; title: string }>> {
226
+ const { stdout, code } = await pi.exec("git", ["log", "--oneline", "-n", `${limit}`]);
227
+ if (code !== 0) return [];
228
+
229
+ return stdout
230
+ .trim()
231
+ .split("\n")
232
+ .map((line) => {
233
+ const spaceIdx = line.indexOf(" ");
234
+ if (spaceIdx === -1) return { sha: line, title: "" };
235
+ return {
236
+ sha: line.slice(0, spaceIdx),
237
+ title: line.slice(spaceIdx + 1).trim(),
49
238
  };
239
+ })
240
+ .filter((c) => c.sha.length > 0);
241
+ }
50
242
 
51
- try {
52
- const parsed = parseReviewArgs(args);
53
- const { config, legacyWarnings } = loadConfig();
54
- for (const w of legacyWarnings) notify(`pi-review 提示: ${w}`, "warning");
55
-
56
- pi.sendMessage({
57
- customType: "pi-review",
58
- content: parsed.input ? `/review ${parsed.input}` : "/review",
59
- display: true,
60
- });
243
+ async function hasUncommittedChanges(pi: ExtensionAPI): Promise<boolean> {
244
+ const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
245
+ return code === 0 && stdout.trim().length > 0;
246
+ }
61
247
 
62
- if (parsed.noSpawn) {
63
- const dryRunText = await renderDryRun(ctx, parsed, config);
64
- pi.sendMessage({ customType: "pi-review", content: dryRunText, display: true });
65
- return;
66
- }
248
+ async function hasPendingChanges(pi: ExtensionAPI): Promise<boolean> {
249
+ const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
250
+ if (code !== 0) return false;
251
+ const lines = stdout.trim().split("\n").filter((line) => line.trim());
252
+ const trackedChanges = lines.filter((line) => !line.startsWith("??"));
253
+ return trackedChanges.length > 0;
254
+ }
67
255
 
68
- const prepared = await prepareRun({
69
- cwd: ctx.cwd,
70
- input: parsed.input,
71
- lite: parsed.lite,
72
- perf: parsed.perf,
73
- full: parsed.full,
74
- gateModel: parsed.gateModel,
75
- });
76
- if (!prepared) {
77
- notify("没有检测到需要审查的内容 (未找到修改、PR 或非 Git 仓库)。", "info");
78
- return;
79
- }
256
+ function parsePrReference(ref: string): number | null {
257
+ const trimmed = ref.trim();
258
+ const num = Number.parseInt(trimmed, 10);
259
+ if (!Number.isNaN(num) && num > 0) return num;
80
260
 
81
- pi.sendMessage(
82
- {
83
- customType: "pi-review-directive",
84
- content: prepared.directiveText,
85
- display: false,
86
- },
87
- { triggerTurn: true },
261
+ const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);
262
+ if (urlMatch) return Number.parseInt(urlMatch[1], 10);
263
+ return null;
264
+ }
265
+
266
+ async function getPrInfo(
267
+ pi: ExtensionAPI,
268
+ prNumber: number,
269
+ ): Promise<{ baseBranch: string; title: string; headBranch: string } | null> {
270
+ const { stdout, code } = await pi.exec("gh", [
271
+ "pr",
272
+ "view",
273
+ String(prNumber),
274
+ "--json",
275
+ "baseRefName,title,headRefName",
276
+ ]);
277
+ if (code !== 0) return null;
278
+
279
+ try {
280
+ const data = JSON.parse(stdout);
281
+ return {
282
+ baseBranch: data.baseRefName,
283
+ title: data.title,
284
+ headBranch: data.headRefName,
285
+ };
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+
291
+ async function checkoutPr(pi: ExtensionAPI, prNumber: number): Promise<{ success: boolean; error?: string }> {
292
+ const { stdout, stderr, code } = await pi.exec("gh", ["pr", "checkout", String(prNumber)]);
293
+ if (code !== 0) {
294
+ return { success: false, error: stderr || stdout || "检出 PR 分支失败" };
295
+ }
296
+ return { success: true };
297
+ }
298
+
299
+ async function getCurrentBranch(pi: ExtensionAPI): Promise<string | null> {
300
+ const { stdout, code } = await pi.exec("git", ["branch", "--show-current"]);
301
+ if (code === 0 && stdout.trim()) return stdout.trim();
302
+ return null;
303
+ }
304
+
305
+ async function getDefaultBranch(pi: ExtensionAPI): Promise<string> {
306
+ const { stdout, code } = await pi.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]);
307
+ if (code === 0 && stdout.trim()) {
308
+ return stdout.trim().replace("origin/", "");
309
+ }
310
+ const branches = await getLocalBranches(pi);
311
+ if (branches.includes("main")) return "main";
312
+ if (branches.includes("master")) return "master";
313
+ return "main";
314
+ }
315
+
316
+ async function buildReviewPrompt(pi: ExtensionAPI, target: ReviewTarget): Promise<string> {
317
+ switch (target.type) {
318
+ case "uncommitted":
319
+ return UNCOMMITTED_PROMPT;
320
+
321
+ case "baseBranch": {
322
+ const mergeBase = await getMergeBase(pi, target.branch);
323
+ if (mergeBase) {
324
+ return BASE_BRANCH_PROMPT_WITH_MERGE_BASE.replace(/{baseBranch}/g, target.branch).replace(
325
+ /{mergeBaseSha}/g,
326
+ mergeBase,
88
327
  );
89
- } catch (err) {
90
- const message = err instanceof Error ? err.message : String(err);
91
- notify(`代码审查启动失败: ${message}`, "error");
92
328
  }
93
- },
329
+ return BASE_BRANCH_PROMPT_FALLBACK.replace(/{branch}/g, target.branch);
330
+ }
331
+
332
+ case "commit":
333
+ if (target.title) {
334
+ return COMMIT_PROMPT_WITH_TITLE.replace("{sha}", target.sha).replace("{title}", target.title);
335
+ }
336
+ return COMMIT_PROMPT.replace("{sha}", target.sha);
337
+
338
+ case "custom":
339
+ return `审查代码并重点满足以下定制要求:${target.instructions}。所有输出必须使用纯正中文。`;
340
+
341
+ case "pullRequest": {
342
+ const mergeBase = await getMergeBase(pi, target.baseBranch);
343
+ if (mergeBase) {
344
+ return PULL_REQUEST_PROMPT.replace(/{prNumber}/g, String(target.prNumber))
345
+ .replace(/{title}/g, target.title)
346
+ .replace(/{baseBranch}/g, target.baseBranch)
347
+ .replace(/{mergeBaseSha}/g, mergeBase);
348
+ }
349
+ return PULL_REQUEST_PROMPT_FALLBACK.replace(/{prNumber}/g, String(target.prNumber))
350
+ .replace(/{title}/g, target.title)
351
+ .replace(/{baseBranch}/g, target.baseBranch);
352
+ }
353
+
354
+ case "folder":
355
+ return FOLDER_REVIEW_PROMPT.replace("{paths}", target.paths.join(", "));
356
+ }
357
+ }
358
+
359
+ function getUserFacingHint(target: ReviewTarget): string {
360
+ switch (target.type) {
361
+ case "uncommitted":
362
+ return "当前未提交改动 (工作区 + 暂存区)";
363
+ case "baseBranch":
364
+ return `与分支 '${target.branch}' 的改动对比`;
365
+ case "commit": {
366
+ const shortSha = target.sha.slice(0, 7);
367
+ return target.title ? `提交 ${shortSha}: ${target.title}` : `提交 ${shortSha}`;
368
+ }
369
+ case "custom":
370
+ return target.instructions.length > 40 ? `${target.instructions.slice(0, 37)}...` : target.instructions;
371
+ case "pullRequest": {
372
+ const shortTitle = target.title.length > 30 ? `${target.title.slice(0, 27)}...` : target.title;
373
+ return `PR #${target.prNumber}: ${shortTitle}`;
374
+ }
375
+ case "folder": {
376
+ const joined = target.paths.join(", ");
377
+ return joined.length > 40 ? `目录: ${joined.slice(0, 37)}...` : `目录: ${joined}`;
378
+ }
379
+ }
380
+ }
381
+
382
+ // 审查预设模式选项清单
383
+ const REVIEW_PRESETS = [
384
+ { value: "uncommitted", label: "审查未提交改动", description: "(当前工作区已暂存 + 未暂存代码)" },
385
+ { value: "baseBranch", label: "与基准分支对比审查", description: "(如与 main / master 分支对比)" },
386
+ { value: "commit", label: "审查指定提交", description: "(从最近提交记录中挑选)" },
387
+ { value: "pullRequest", label: "审查 Pull Request", description: "(输入 PR 编号或 GitHub URL 本地检出)" },
388
+ { value: "folder", label: "审查指定目录/文件", description: "(静态快照审查,非 diff)" },
389
+ { value: "custom", label: "自定义审查要求", description: "(输入针对性的安全/性能侧重点)" },
390
+ ] as const;
391
+
392
+ export default function reviewExtension(pi: ExtensionAPI) {
393
+ pi.on("session_start", (_event, ctx) => {
394
+ applyReviewState(ctx);
94
395
  });
95
396
 
96
- pi.registerCommand("review-lite", {
97
- description: "极速单兵代码审查 (无门禁,3~5秒出结果,极省 Token)",
98
- handler: async (args, ctx) => {
99
- const notify = (msg: string, level: "info" | "warning" | "error" = "info") => {
100
- if (ctx.hasUI) ctx.ui.notify(msg, level);
101
- else console.log(`pi-review: ${msg}`);
102
- };
103
- try {
104
- const { config, legacyWarnings } = loadConfig();
105
- for (const w of legacyWarnings) notify(`pi-review 提示: ${w}`, "warning");
106
- pi.sendMessage({ customType: "pi-review", content: args ? `/review-lite ${args}` : "/review-lite", display: true });
107
- const prepared = await prepareRun({ cwd: ctx.cwd, input: args, lite: true });
108
- if (!prepared) {
109
- notify("没有检测到需要审查的内容 (未找到修改、PR 或非 Git 仓库)。", "info");
110
- return;
397
+ pi.on("session_tree", (_event, ctx) => {
398
+ applyReviewState(ctx);
399
+ });
400
+
401
+ async function getSmartDefault(): Promise<"uncommitted" | "baseBranch" | "commit"> {
402
+ if (await hasUncommittedChanges(pi)) {
403
+ return "uncommitted";
404
+ }
405
+ const currentBranch = await getCurrentBranch(pi);
406
+ const defaultBranch = await getDefaultBranch(pi);
407
+ if (currentBranch && currentBranch !== defaultBranch) {
408
+ return "baseBranch";
409
+ }
410
+ return "commit";
411
+ }
412
+
413
+ async function showReviewSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
414
+ const smartDefault = await getSmartDefault();
415
+ const items: SelectItem[] = REVIEW_PRESETS.slice()
416
+ .sort((a, b) => {
417
+ if (a.value === smartDefault) return -1;
418
+ if (b.value === smartDefault) return 1;
419
+ return 0;
420
+ })
421
+ .map((preset) => ({
422
+ value: preset.value,
423
+ label: preset.label,
424
+ description: preset.description,
425
+ }));
426
+
427
+ while (true) {
428
+ const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
429
+ const container = new Container();
430
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
431
+ container.addChild(new Text(theme.fg("accent", theme.bold("请选择代码审查模式"))));
432
+
433
+ const selectList = new SelectList(items, Math.min(items.length, 10), {
434
+ selectedPrefix: (text) => theme.fg("accent", text),
435
+ selectedText: (text) => theme.fg("accent", text),
436
+ description: (text) => theme.fg("muted", text),
437
+ scrollInfo: (text) => theme.fg("dim", text),
438
+ noMatch: (text) => theme.fg("warning", text),
439
+ });
440
+
441
+ selectList.onSelect = (item) => done(item.value);
442
+ selectList.onCancel = () => done(null);
443
+
444
+ container.addChild(selectList);
445
+ container.addChild(new Text(theme.fg("dim", "回车确认 • ESC 取消")));
446
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
447
+
448
+ return {
449
+ render(width: number) {
450
+ return container.render(width);
451
+ },
452
+ invalidate() {
453
+ container.invalidate();
454
+ },
455
+ handleInput(data: string) {
456
+ selectList.handleInput(data);
457
+ tui.requestRender();
458
+ },
459
+ };
460
+ });
461
+
462
+ if (!result) return null;
463
+
464
+ switch (result) {
465
+ case "uncommitted":
466
+ return { type: "uncommitted" };
467
+
468
+ case "baseBranch": {
469
+ const target = await showBranchSelector(ctx);
470
+ if (target) return target;
471
+ break;
472
+ }
473
+
474
+ case "commit": {
475
+ const target = await showCommitSelector(ctx);
476
+ if (target) return target;
477
+ break;
478
+ }
479
+
480
+ case "custom": {
481
+ const target = await showCustomInput(ctx);
482
+ if (target) return target;
483
+ break;
111
484
  }
112
- pi.sendMessage({ customType: "pi-review-directive", content: prepared.directiveText, display: false }, { triggerTurn: true });
113
- } catch (err) {
114
- const message = err instanceof Error ? err.message : String(err);
115
- notify(`极速代码审查启动失败: ${message}`, "error");
485
+
486
+ case "folder": {
487
+ const target = await showFolderInput(ctx);
488
+ if (target) return target;
489
+ break;
490
+ }
491
+
492
+ case "pullRequest": {
493
+ const target = await showPrInput(ctx);
494
+ if (target) return target;
495
+ break;
496
+ }
497
+
498
+ default:
499
+ return null;
116
500
  }
117
- },
118
- });
501
+ }
502
+ }
119
503
 
120
- pi.registerCommand("review-perf", {
121
- description: "专项性能与基准测试审查 (GC堆分配/算法复杂度/Benchmark探针)",
122
- handler: async (args, ctx) => {
123
- const notify = (msg: string, level: "info" | "warning" | "error" = "info") => {
124
- if (ctx.hasUI) ctx.ui.notify(msg, level);
125
- else console.log(`pi-review: ${msg}`);
504
+ async function showBranchSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
505
+ const branches = await getLocalBranches(pi);
506
+ const defaultBranch = await getDefaultBranch(pi);
507
+
508
+ if (branches.length === 0) {
509
+ ctx.ui.notify("未找到任何本地分支", "error");
510
+ return null;
511
+ }
512
+
513
+ const sortedBranches = branches.sort((a, b) => {
514
+ if (a === defaultBranch) return -1;
515
+ if (b === defaultBranch) return 1;
516
+ return a.localeCompare(b);
517
+ });
518
+
519
+ const items: SelectItem[] = sortedBranches.map((branch) => ({
520
+ value: branch,
521
+ label: branch,
522
+ description: branch === defaultBranch ? "(默认主分支)" : "",
523
+ }));
524
+
525
+ const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
526
+ const container = new Container();
527
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
528
+ container.addChild(new Text(theme.fg("accent", theme.bold("请选择要对比的基准分支"))));
529
+
530
+ const selectList = new SelectList(items, Math.min(items.length, 10), {
531
+ selectedPrefix: (text) => theme.fg("accent", text),
532
+ selectedText: (text) => theme.fg("accent", text),
533
+ description: (text) => theme.fg("muted", text),
534
+ scrollInfo: (text) => theme.fg("dim", text),
535
+ noMatch: (text) => theme.fg("warning", text),
536
+ });
537
+
538
+ selectList.searchable = true;
539
+ selectList.onSelect = (item) => done(item.value);
540
+ selectList.onCancel = () => done(null);
541
+
542
+ container.addChild(selectList);
543
+ container.addChild(new Text(theme.fg("dim", "输入关键词可快速搜索 • 回车选择 • ESC 取消")));
544
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
545
+
546
+ return {
547
+ render(width: number) {
548
+ return container.render(width);
549
+ },
550
+ invalidate() {
551
+ container.invalidate();
552
+ },
553
+ handleInput(data: string) {
554
+ selectList.handleInput(data);
555
+ tui.requestRender();
556
+ },
557
+ };
558
+ });
559
+
560
+ if (!result) return null;
561
+ return { type: "baseBranch", branch: result };
562
+ }
563
+
564
+ async function showCommitSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
565
+ const commits = await getRecentCommits(pi, 20);
566
+
567
+ if (commits.length === 0) {
568
+ ctx.ui.notify("未找到最近的提交记录", "error");
569
+ return null;
570
+ }
571
+
572
+ const items: SelectItem[] = commits.map((commit) => ({
573
+ value: commit.sha,
574
+ label: `${commit.sha.slice(0, 7)} ${commit.title}`,
575
+ description: "",
576
+ }));
577
+
578
+ const result = await ctx.ui.custom<{ sha: string; title: string } | null>((tui, theme, _kb, done) => {
579
+ const container = new Container();
580
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
581
+ container.addChild(new Text(theme.fg("accent", theme.bold("请选择要审查的 Commit"))));
582
+
583
+ const selectList = new SelectList(items, Math.min(items.length, 10), {
584
+ selectedPrefix: (text) => theme.fg("accent", text),
585
+ selectedText: (text) => theme.fg("accent", text),
586
+ description: (text) => theme.fg("muted", text),
587
+ scrollInfo: (text) => theme.fg("dim", text),
588
+ noMatch: (text) => theme.fg("warning", text),
589
+ });
590
+
591
+ selectList.searchable = true;
592
+ selectList.onSelect = (item) => {
593
+ const commit = commits.find((c) => c.sha === item.value);
594
+ done(commit || null);
126
595
  };
596
+ selectList.onCancel = () => done(null);
597
+
598
+ container.addChild(selectList);
599
+ container.addChild(new Text(theme.fg("dim", "输入可搜索 • 回车选择 • ESC 取消")));
600
+ container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
601
+
602
+ return {
603
+ render(width: number) {
604
+ return container.render(width);
605
+ },
606
+ invalidate() {
607
+ container.invalidate();
608
+ },
609
+ handleInput(data: string) {
610
+ selectList.handleInput(data);
611
+ tui.requestRender();
612
+ },
613
+ };
614
+ });
615
+
616
+ if (!result) return null;
617
+ return { type: "commit", sha: result.sha, title: result.title };
618
+ }
619
+
620
+ async function showCustomInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
621
+ const result = await ctx.ui.editor(
622
+ "请输入本次审查的具体要求与侧重点 (如并发安全、GC开销、特定接口逻辑):",
623
+ "重点排查潜在的并发死锁、内存泄露以及外部输入合法性校验...",
624
+ );
625
+
626
+ if (!result?.trim()) return null;
627
+ return { type: "custom", instructions: result.trim() };
628
+ }
629
+
630
+ function parseReviewPaths(value: string): string[] {
631
+ return value
632
+ .split(/\s+/)
633
+ .map((item) => item.trim())
634
+ .filter((item) => item.length > 0);
635
+ }
636
+
637
+ async function showFolderInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
638
+ const result = await ctx.ui.editor(
639
+ "请输入要审查的文件或目录路径 (多个路径使用空格或换行分隔):",
640
+ ".",
641
+ );
642
+
643
+ if (!result?.trim()) return null;
644
+ const paths = parseReviewPaths(result);
645
+ if (paths.length === 0) return null;
646
+
647
+ return { type: "folder", paths };
648
+ }
649
+
650
+ async function showPrInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
651
+ if (await hasPendingChanges(pi)) {
652
+ ctx.ui.notify("无法检出 PR:工作区存在未提交改动,请先提交或暂存 (git stash)。", "error");
653
+ return null;
654
+ }
655
+
656
+ const prRef = await ctx.ui.editor(
657
+ "请输入 PR 编号或 GitHub URL (如 123 或 https://github.com/owner/repo/pull/123):",
658
+ "",
659
+ );
660
+
661
+ if (!prRef?.trim()) return null;
662
+
663
+ const prNumber = parsePrReference(prRef);
664
+ if (!prNumber) {
665
+ ctx.ui.notify("无效的 PR 格式,请输入纯数字编号或 GitHub PR 网页链接。", "error");
666
+ return null;
667
+ }
668
+
669
+ ctx.ui.notify(`正在获取 PR #${prNumber} 元数据...`, "info");
670
+ const prInfo = await getPrInfo(pi, prNumber);
671
+
672
+ if (!prInfo) {
673
+ ctx.ui.notify(`未找到 PR #${prNumber}。请确认已登录 gh (GitHub CLI) 且 PR 存在。`, "error");
674
+ return null;
675
+ }
676
+
677
+ ctx.ui.notify(`正在本地检出 PR #${prNumber} 分支...`, "info");
678
+ const checkoutResult = await checkoutPr(pi, prNumber);
679
+
680
+ if (!checkoutResult.success) {
681
+ ctx.ui.notify(`检出 PR 失败: ${checkoutResult.error}`, "error");
682
+ return null;
683
+ }
684
+
685
+ ctx.ui.notify(`已成功检出 PR #${prNumber} (${prInfo.headBranch})`, "info");
686
+
687
+ return {
688
+ type: "pullRequest",
689
+ prNumber,
690
+ baseBranch: prInfo.baseBranch,
691
+ title: prInfo.title,
692
+ };
693
+ }
694
+
695
+ async function executeReview(ctx: ExtensionCommandContext, target: ReviewTarget, useFreshSession: boolean): Promise<void> {
696
+ if (reviewOriginId) {
697
+ ctx.ui.notify("当前已有正在进行的审查会话。请先使用 /end-review 结束。", "warning");
698
+ return;
699
+ }
700
+
701
+ if (useFreshSession) {
702
+ const originId = ctx.sessionManager.getLeafId() ?? undefined;
703
+ if (!originId) {
704
+ ctx.ui.notify("无法获取当前会话位置,请在有消息的会话中重试。", "error");
705
+ return;
706
+ }
707
+ reviewOriginId = originId;
708
+ const lockedOriginId = originId;
709
+
710
+ const entries = ctx.sessionManager.getEntries();
711
+ const firstUserMessage = entries.find((e) => e.type === "message" && e.message.role === "user");
712
+
713
+ if (!firstUserMessage) {
714
+ ctx.ui.notify("当前会话中未找到任何用户消息", "error");
715
+ reviewOriginId = undefined;
716
+ return;
717
+ }
718
+
127
719
  try {
128
- const { config, legacyWarnings } = loadConfig();
129
- for (const w of legacyWarnings) notify(`pi-review 提示: ${w}`, "warning");
130
- pi.sendMessage({ customType: "pi-review", content: args ? `/review-perf ${args}` : "/review-perf", display: true });
131
- const prepared = await prepareRun({ cwd: ctx.cwd, input: args, perf: true });
132
- if (!prepared) {
133
- notify("没有检测到需要审查的内容 (未找到修改、PR 或非 Git 仓库)。", "info");
720
+ const result = await ctx.navigateTree(firstUserMessage.id, { summarize: false, label: "代码审查" });
721
+ if (result.cancelled) {
722
+ reviewOriginId = undefined;
134
723
  return;
135
724
  }
136
- pi.sendMessage({ customType: "pi-review-directive", content: prepared.directiveText, display: false }, { triggerTurn: true });
137
- } catch (err) {
138
- const message = err instanceof Error ? err.message : String(err);
139
- notify(`性能审查启动失败: ${message}`, "error");
725
+ } catch (error) {
726
+ reviewOriginId = undefined;
727
+ ctx.ui.notify(`启动审查分支失败: ${error instanceof Error ? error.message : String(error)}`, "error");
728
+ return;
140
729
  }
141
- },
142
- });
143
730
 
144
- pi.registerCommand("review-full", {
145
- description: "全量 6 专家深度代码审查会诊 (Bugbot/安全/合规/历史/注释/性能 + 门禁总裁判)",
731
+ reviewOriginId = lockedOriginId;
732
+ ctx.ui.setEditorText("");
733
+ setReviewWidget(ctx, true);
734
+ pi.appendEntry(REVIEW_STATE_TYPE, { active: true, originId: lockedOriginId });
735
+ }
736
+
737
+ const prompt = await buildReviewPrompt(pi, target);
738
+ const hint = getUserFacingHint(target);
739
+ const projectGuidelines = await loadProjectReviewGuidelines(ctx.cwd);
740
+
741
+ let fullPrompt = `${REVIEW_RUBRIC}\n\n---\n\n## 本次审查目标与任务指示\n\n${prompt}`;
742
+
743
+ if (projectGuidelines) {
744
+ fullPrompt += `\n\n## 本项目附加规范指南\n\n${projectGuidelines}`;
745
+ }
746
+
747
+ const modeHint = useFreshSession ? " (独立审查分支)" : "";
748
+ ctx.ui.notify(`正在启动代码审查: ${hint}${modeHint}`, "info");
749
+
750
+ pi.sendUserMessage(fullPrompt);
751
+ }
752
+
753
+ function parseArgs(args: string | undefined): ReviewTarget | { type: "pr"; ref: string } | null {
754
+ if (!args?.trim()) return null;
755
+
756
+ const parts = args.trim().split(/\s+/);
757
+ const subcommand = parts[0]?.toLowerCase();
758
+
759
+ switch (subcommand) {
760
+ case "uncommitted":
761
+ case "--uncommitted":
762
+ case "diff":
763
+ return { type: "uncommitted" };
764
+
765
+ case "branch":
766
+ case "--branch": {
767
+ const branch = parts[1];
768
+ if (!branch) return null;
769
+ return { type: "baseBranch", branch };
770
+ }
771
+
772
+ case "commit":
773
+ case "--commit": {
774
+ const sha = parts[1];
775
+ if (!sha) return null;
776
+ const title = parts.slice(2).join(" ") || undefined;
777
+ return { type: "commit", sha, title };
778
+ }
779
+
780
+ case "custom":
781
+ case "--custom": {
782
+ const instructions = parts.slice(1).join(" ");
783
+ if (!instructions) return null;
784
+ return { type: "custom", instructions };
785
+ }
786
+
787
+ case "folder":
788
+ case "--folder": {
789
+ const paths = parseReviewPaths(parts.slice(1).join(" "));
790
+ if (paths.length === 0) return null;
791
+ return { type: "folder", paths };
792
+ }
793
+
794
+ case "pr":
795
+ case "--pr": {
796
+ const ref = parts[1];
797
+ if (!ref) return null;
798
+ return { type: "pr", ref };
799
+ }
800
+
801
+ default:
802
+ return { type: "custom", instructions: args.trim() };
803
+ }
804
+ }
805
+
806
+ // 注册 /review 主命令
807
+ pi.registerCommand("review", {
808
+ description: "启动 AI 代码审查 (交互式选择:未提交改动/分支对比/指定Commit/PR/目录/自定义)",
146
809
  handler: async (args, ctx) => {
147
- const notify = (msg: string, level: "info" | "warning" | "error" = "info") => {
148
- if (ctx.hasUI) ctx.ui.notify(msg, level);
149
- else console.log(`pi-review: ${msg}`);
150
- };
151
- try {
152
- const { config, legacyWarnings } = loadConfig();
153
- for (const w of legacyWarnings) notify(`pi-review 提示: ${w}`, "warning");
154
- pi.sendMessage({ customType: "pi-review", content: args ? `/review-full ${args}` : "/review-full", display: true });
155
- const prepared = await prepareRun({ cwd: ctx.cwd, input: args, full: true });
156
- if (!prepared) {
157
- notify("没有检测到需要审查的内容 (未找到修改、PR 或非 Git 仓库)。", "info");
158
- return;
810
+ if (!ctx.hasUI) {
811
+ ctx.ui.notify("代码审查需要交互式终端环境", "error");
812
+ return;
813
+ }
814
+
815
+ if (reviewOriginId) {
816
+ ctx.ui.notify("当前已有正在进行的审查。请先输入 /end-review 完成审查并返回。", "warning");
817
+ return;
818
+ }
819
+
820
+ const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
821
+ if (code !== 0) {
822
+ ctx.ui.notify("当前目录不是有效的 Git 仓库", "error");
823
+ return;
824
+ }
825
+
826
+ let target: ReviewTarget | null = null;
827
+ let fromSelector = false;
828
+ const parsed = parseArgs(args);
829
+
830
+ if (parsed) {
831
+ if (parsed.type === "pr") {
832
+ target = await handlePrCheckout(ctx, parsed.ref);
833
+ if (!target) {
834
+ ctx.ui.notify("PR 检出失败,返回主菜单。", "warning");
835
+ }
836
+ } else {
837
+ target = parsed;
159
838
  }
160
- pi.sendMessage({ customType: "pi-review-directive", content: prepared.directiveText, display: false }, { triggerTurn: true });
161
- } catch (err) {
162
- const message = err instanceof Error ? err.message : String(err);
163
- notify(`全量代码审查启动失败: ${message}`, "error");
164
839
  }
165
- },
166
- });
167
840
 
168
- pi.registerCommand("review-config", {
169
- description: "编辑代码审查配置 (~/.pi/agent/pi-review.json)",
170
- handler: async (_args, ctx) => {
171
- const path = configPath();
172
- if (!existsSync(path)) {
173
- writeConfig(DEFAULT_CONFIG);
841
+ if (!target) {
842
+ fromSelector = true;
174
843
  }
175
844
 
176
- let raw: string;
177
- if (ctx.hasUI) {
178
- const current = readFileSync(path, "utf-8");
179
- const edited = await ctx.ui.editor("编辑 pi-review 配置文件 (JSON)", current);
180
- if (edited === undefined) {
181
- ctx.ui.notify("已取消编辑配置。", "info");
182
- return;
845
+ while (true) {
846
+ if (!target && fromSelector) {
847
+ target = await showReviewSelector(ctx);
183
848
  }
184
- raw = edited;
185
- } else {
186
- const editor = process.env.VISUAL ?? process.env.EDITOR;
187
- if (!editor) {
188
- ctx.ui.notify(`请设置 $EDITOR 或在 TUI 模式下使用。配置文件路径: ${path}`, "warning");
849
+
850
+ if (!target) {
851
+ ctx.ui.notify("已取消代码审查", "info");
189
852
  return;
190
853
  }
191
- await pi.exec(editor, [path], { cwd: ctx.cwd });
192
- raw = readFileSync(path, "utf-8");
193
- }
194
854
 
195
- let parsed: unknown;
196
- try {
197
- parsed = JSON.parse(raw);
198
- } catch {
199
- ctx.ui.notify("JSON 格式无效 — 配置未保存。", "error");
200
- return;
201
- }
855
+ const entries = ctx.sessionManager.getEntries();
856
+ const messageCount = entries.filter((e) => e.type === "message").length;
857
+
858
+ let useFreshSession = false;
859
+
860
+ if (messageCount > 0) {
861
+ const choice = await ctx.ui.select("选择审查执行环境:", [
862
+ "独立分支审查 (推荐,主会话保持干净)",
863
+ "当前会话直接审查",
864
+ ]);
865
+
866
+ if (choice === undefined) {
867
+ if (fromSelector) {
868
+ target = null;
869
+ continue;
870
+ }
871
+ ctx.ui.notify("已取消代码审查", "info");
872
+ return;
873
+ }
874
+
875
+ useFreshSession = choice.startsWith("独立分支审查");
876
+ }
202
877
 
203
- const merged = mergeWithDefaults(parsed);
204
- const validation = validateConfig(merged);
205
- if (!validation.ok) {
206
- ctx.ui.notify(`配置存在错误: ${validation.errors.join("; ")}`, "error");
878
+ await executeReview(ctx, target, useFreshSession);
207
879
  return;
208
880
  }
209
- writeConfig(merged);
210
- ctx.ui.notify("pi-review 配置文件已保存。", "info");
211
881
  },
212
882
  });
213
883
 
214
- pi.registerCommand("review-agents", {
215
- description: "查看当前内置审查专家与模型分配清单",
884
+ // 极速单兵别名命令:/review-lite
885
+ pi.registerCommand("review-lite", {
886
+ description: "极速代码审查 (直接对当前工作区未提交改动做深度审查,无需弹窗选择)",
216
887
  handler: async (_args, ctx) => {
217
- const { config } = loadConfig();
218
- const parent = parentModelId(ctx);
219
- const lines: string[] = ["## 🔍 pi-review 审查专家与门禁状态", ""];
220
- for (const r of Object.values(config.reviewers)) {
221
- const model = resolveModel(r.model, parent);
222
- const status = r.enabled ? "已启用 (enabled)" : "已禁用 (disabled)";
223
- lines.push(`- **${r.id}** (${r.label}) — ${status}`);
224
- lines.push(` - 模型: ${model}`);
225
- lines.push(` - 思考深度: ${r.thinking ?? "跟随主会话"}`);
888
+ if (!ctx.hasUI) {
889
+ ctx.ui.notify("代码审查需要交互式终端环境", "error");
890
+ return;
891
+ }
892
+
893
+ if (reviewOriginId) {
894
+ ctx.ui.notify("当前已有正在进行的审查。请先输入 /end-review 完成审查并返回。", "warning");
895
+ return;
896
+ }
897
+
898
+ const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
899
+ if (code !== 0) {
900
+ ctx.ui.notify("当前目录不是有效的 Git 仓库", "error");
901
+ return;
226
902
  }
227
- lines.push("");
228
- lines.push(`门禁裁判长 (Gate): ${resolveModel(config.gate.model, parent)} · 过滤置信度阈值: ${config.gate.threshold} · 判定策略: ${config.gate.verdictPolicy}`);
229
- lines.push(`路由模式: ${config.routing.mode}`);
230
- const body = lines.join("\n");
231
- pi.sendMessage({ customType: "pi-review-agents", content: body, display: true });
903
+
904
+ await executeReview(ctx, { type: "uncommitted" }, false);
232
905
  },
233
906
  });
234
907
 
235
- pi.registerCommand("review-show", {
236
- description: "重新显示最近一次的代码审查报告",
908
+ // 审查总结专用提示词
909
+ const REVIEW_SUMMARY_PROMPT = `我们即将结束代码审查并切回开发主对话。
910
+ 请将本次代码审查分支中发现的所有核心问题、缺陷与建议生成一份结构清晰的中文整改总结。
911
+
912
+ 必须严格按以下格式输出总结(确保原样保留文件路径、行号与缺陷等级):
913
+
914
+ ## 待办修复清单 (Next Steps)
915
+ 1. [需优先解决的 P0/P1 问题]
916
+
917
+ ## 代码审查发现归档 (Code Review Findings)
918
+
919
+ ### [P0|P1|P2|P3] 问题标题
920
+ - 位置:path/to/file.ext:行号
921
+ - 说明:缺陷描述与引发场景
922
+ - 修复:最小修复建议
923
+ `;
924
+
925
+ // 注册 /end-review 结束审查并返回命令
926
+ pi.registerCommand("end-review", {
927
+ description: "完成代码审查并一键返回主会话位置 (自动汇总待办并回填修复指令)",
237
928
  handler: async (_args, ctx) => {
238
- let last: { markdown?: string } | null = null;
239
- for (const entry of ctx.sessionManager.getBranch()) {
240
- if (
241
- entry.type === "custom" &&
242
- (entry as { customType?: string }).customType === "pi-review" &&
243
- (entry as { data?: { markdown?: string } }).data?.markdown
244
- ) {
245
- last = (entry as { data: { markdown?: string } }).data;
929
+ if (!ctx.hasUI) {
930
+ ctx.ui.notify("end-review 需要交互式终端环境", "error");
931
+ return;
932
+ }
933
+
934
+ if (!reviewOriginId) {
935
+ const state = getReviewState(ctx);
936
+ if (state?.active && state.originId) {
937
+ reviewOriginId = state.originId;
938
+ } else if (state?.active) {
939
+ setReviewWidget(ctx, false);
940
+ pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
941
+ ctx.ui.notify("未检测到分支关联信息,已重置审查状态。", "warning");
942
+ return;
943
+ } else {
944
+ ctx.ui.notify("当前不在独立审查分支中 (当前审查是在主会话模式下进行的,无需返回)。", "info");
945
+ return;
246
946
  }
247
947
  }
248
- if (!last?.markdown) {
249
- ctx.ui.notify("当前会话中未找到任何历史审查报告。", "info");
948
+
949
+ const summaryChoice = await ctx.ui.select("是否将审查结果汇总后返回?", [
950
+ "汇总审查结果并返回主分支 (生成修复待办)",
951
+ "直接返回 (不生成总结)",
952
+ ]);
953
+
954
+ if (summaryChoice === undefined) {
955
+ ctx.ui.notify("已取消。输入 /end-review 可再次返回。", "info");
250
956
  return;
251
957
  }
252
- pi.sendMessage({ customType: "pi-review", content: last.markdown, display: true });
253
- },
254
- });
255
- }
256
958
 
257
- /** Cheap human summary for `--no-spawn`. */
258
- async function renderDryRun(
259
- ctx: ExtensionCommandContext,
260
- parsed: ReturnType<typeof parseReviewArgs>,
261
- config: ReturnType<typeof loadConfig>["config"],
262
- ): Promise<string> {
263
- const prepared = await prepareRun({
264
- cwd: ctx.cwd,
265
- input: parsed.input,
266
- lite: parsed.lite,
267
- gateModel: parsed.gateModel,
268
- cleanup: false,
959
+ const wantsSummary = summaryChoice.startsWith("汇总审查结果");
960
+ const originId = reviewOriginId;
961
+
962
+ if (wantsSummary) {
963
+ const result = await ctx.ui.custom<{ cancelled: boolean; error?: string } | null>((tui, theme, _kb, done) => {
964
+ const loader = new BorderedLoader(tui, theme, "正在汇总代码审查报告并返回主分支...");
965
+ loader.onAbort = () => done(null);
966
+
967
+ ctx.navigateTree(originId!, {
968
+ summarize: true,
969
+ customInstructions: REVIEW_SUMMARY_PROMPT,
970
+ replaceInstructions: true,
971
+ })
972
+ .then(done)
973
+ .catch((err) => done({ cancelled: false, error: err instanceof Error ? err.message : String(err) }));
974
+
975
+ return loader;
976
+ });
977
+
978
+ if (result === null) {
979
+ ctx.ui.notify("已取消返回操作。", "info");
980
+ return;
981
+ }
982
+
983
+ if (result.error) {
984
+ ctx.ui.notify(`返回主分支失败: ${result.error}`, "error");
985
+ return;
986
+ }
987
+
988
+ setReviewWidget(ctx, false);
989
+ reviewOriginId = undefined;
990
+ pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
991
+
992
+ if (result.cancelled) {
993
+ ctx.ui.notify("导航已取消", "info");
994
+ return;
995
+ }
996
+
997
+ if (!ctx.ui.getEditorText().trim()) {
998
+ ctx.ui.setEditorText("根据上述代码审查发现进行修改修复");
999
+ }
1000
+
1001
+ ctx.ui.notify("代码审查已完成!已顺利返回主会话开发分支。", "info");
1002
+ } else {
1003
+ try {
1004
+ const result = await ctx.navigateTree(originId!, { summarize: false });
1005
+
1006
+ if (result.cancelled) {
1007
+ ctx.ui.notify("导航已取消", "info");
1008
+ return;
1009
+ }
1010
+
1011
+ setReviewWidget(ctx, false);
1012
+ reviewOriginId = undefined;
1013
+ pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
1014
+ ctx.ui.notify("代码审查已结束,已直接返回主会话。", "info");
1015
+ } catch (error) {
1016
+ ctx.ui.notify(`返回失败: ${error instanceof Error ? error.message : String(error)}`, "error");
1017
+ }
1018
+ }
1019
+ },
269
1020
  });
270
- if (!prepared) return "pi-review 试运行: 没有找到需要审查的代码。";
271
- const m = prepared.manifest;
272
- const gate = config.gate;
273
- const lines = [
274
- "pi-review 试运行摘要",
275
- `审查目标: ${m.targetLabel}`,
276
- `模式: ${parsed.lite ? "极速单专家 (无门禁)" : `多专家并发 (${m.targetKind})`}`,
277
- `运行 ID: ${m.runId}`,
278
- `工作区目录: ${m.workspacePath}`,
279
- `Diff 哈希: ${m.diffSha256.slice(0, 16)}…`,
280
- `变更文件数: ${m.changedFiles.length}`,
281
- `纯文档变更: ${m.docsOnly ? "是" : "否"}`,
282
- `包含 Git 历史: ${m.historyAvailable ? "是" : "否"}`,
283
- `置信度阈值: ${gate.threshold}`,
284
- `门禁裁判: ${gate.enabled ? `已启用 (${resolveModel(gate.model, undefined)})` : "未启用"}`,
285
- ];
286
- if (m.rulePaths.length > 0) lines.push(`规范文件: ${m.rulePaths.join(", ")}`);
287
- return lines.join("\n");
288
1021
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@unifan/pi-review-zh",
3
- "version": "1.0.11",
4
- "description": "Pi AI 并发代码审查扩展(多专家子代理并发 + 门禁裁判系统,中文增强版)",
3
+ "version": "1.0.12",
4
+ "description": "Pi 交互式 AI 代码审查扩展(Codex & pi-agent-extensions 架构,全中文,支持分支隔离、未提交改动、分支对比、PR 审查)",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "821869798",