@zephyrdeng/pi-review 0.6.0 → 0.6.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 CHANGED
@@ -224,9 +224,12 @@ The `/rv` command injects a task message for the parent agent (Pi host rules: de
224
224
 
225
225
  Argument completions are context-aware. After the host session starts, `/rv` reads the live model registry and offers:
226
226
 
227
- - **Model list** after `--model `, ranked by review fit (reasoning, context window, cross-vendor bonus for adversarial review) with a `★ 推荐` marker and a one-line reason.
228
- - **Thinking suffix** after `provider/model:` — only levels the chosen model actually supports (e.g. `:high`, `:xhigh`); non-reasoning models offer none.
229
- - **`--mode` / `--thinking` / flag** completions with hints, and **scene templates** (e.g. “审代码改动(推荐配置)”) at the top level that pre-fill a recommended command.
227
+ - **Model list** after `--model `: candidates come from the live Pi registry; order follows **`resources/rv-model-priorities.json`** (override with `PI_REVIEW_RV_PRIORITIES`). Presets match registry ids by substring and prefer newer version strings (e.g. kimi `2.7`, `claude-opus-4-8`). Profiles: **code** (e.g. gpt-5.5 `:xhigh`, glm-5.2 `:high`), **frontend** (vue/css/… → kimi, claude-sonnet, minimax-m3), **plan/challenge** (claude-opus-4-8, deepseek-v4-pro, …).
228
+ - **Thinking suffix** after `provider/model:` — only levels the chosen model actually supports.
229
+ - **Semantic phrases** (e.g. `code review` / 代码审核, `查看模型列表`) in addition to flags; orchestration prompts follow **session locale** (zh/en) for summaries.
230
+ - **Scene templates** at the top level (code / frontend / plan presets).
231
+
232
+ **Claude Code / Codex:** the bundled skill includes **[skills/pi-review/references/model-selection.md](skills/pi-review/references/model-selection.md)** — same presets as `/rv` for choosing `--model` after `pi-review models`.
230
233
 
231
234
  Completions are a hint layer only; execution remains skill-driven. When the model registry is unavailable (e.g. non-TUI mode), `/rv` falls back to the static hint list.
232
235
 
@@ -3,6 +3,7 @@ import {
3
3
  buildRvCompletions,
4
4
  type ModelInfo,
5
5
  } from "./rv-completions.js";
6
+ import { detectRvLocale } from "./rv-locale.js";
6
7
  import {
7
8
  buildRvOrchestrationPrompt,
8
9
  parseRvArgs,
@@ -18,6 +19,28 @@ import {
18
19
  */
19
20
  let capturedModels: ModelInfo[] | undefined;
20
21
  let capturedPrimaryProvider: string | undefined;
22
+ let capturedLocale: ReturnType<typeof detectRvLocale> = "en";
23
+
24
+ function sampleSessionText(
25
+ sessionManager: { getEntries?: () => Array<{ type?: string; content?: unknown }> },
26
+ ): string[] {
27
+ const out: string[] = [];
28
+ try {
29
+ const entries = sessionManager.getEntries?.() ?? [];
30
+ for (let i = entries.length - 1; i >= 0 && out.length < 12; i--) {
31
+ const e = entries[i];
32
+ if (e.type !== "message") continue;
33
+ const c = e.content;
34
+ if (typeof c === "string") out.push(c);
35
+ else if (c && typeof c === "object" && "text" in c && typeof (c as { text: string }).text === "string") {
36
+ out.push((c as { text: string }).text);
37
+ }
38
+ }
39
+ } catch {
40
+ /* ignore */
41
+ }
42
+ return out;
43
+ }
21
44
 
22
45
  function toModelInfo(m: {
23
46
  provider: string;
@@ -50,12 +73,22 @@ export default function piReviewExtension(pi: ExtensionAPI) {
50
73
  const available = ctx.modelRegistry?.getAvailable?.() ?? [];
51
74
  capturedModels = available.map((m) => toModelInfo(m as unknown as Parameters<typeof toModelInfo>[0]));
52
75
  capturedPrimaryProvider = ctx.model?.provider;
76
+ capturedLocale = detectRvLocale(sampleSessionText(ctx.sessionManager));
53
77
  } catch {
54
78
  capturedModels = undefined;
55
79
  capturedPrimaryProvider = undefined;
80
+ capturedLocale = "en";
56
81
  }
57
82
  });
58
83
 
84
+ function localeForHandler(ctx: { sessionManager?: Parameters<typeof sampleSessionText>[0] }): typeof capturedLocale {
85
+ try {
86
+ return detectRvLocale(sampleSessionText(ctx.sessionManager ?? { getEntries: () => [] }));
87
+ } catch {
88
+ return capturedLocale;
89
+ }
90
+ }
91
+
59
92
  pi.registerCommand("rv", {
60
93
  description:
61
94
  "Delegate pi-review to the agent. Usage: /rv [--mode plan|challenge] [--model id] [--thinking level] [--keep-session] [--no-stream] @target | /rv --continue <handle> [opts] [text] | models",
@@ -65,6 +98,7 @@ export default function piReviewExtension(pi: ExtensionAPI) {
65
98
  const dynamic = buildRvCompletions(prefix, {
66
99
  models: capturedModels,
67
100
  primaryProvider: capturedPrimaryProvider,
101
+ locale: capturedLocale,
68
102
  });
69
103
  if (dynamic && dynamic.length) return dynamic;
70
104
  }
@@ -93,7 +127,7 @@ export default function piReviewExtension(pi: ExtensionAPI) {
93
127
  return;
94
128
  }
95
129
 
96
- pi.sendUserMessage(buildRvOrchestrationPrompt(parsed));
130
+ pi.sendUserMessage(buildRvOrchestrationPrompt(parsed, localeForHandler(ctx)));
97
131
  },
98
132
  });
99
133
  }
@@ -6,10 +6,11 @@ import {
6
6
  fuzzyMatch,
7
7
  MODE_HINTS,
8
8
  rankModelsForReview,
9
- scoreModelForReview,
10
9
  tokenizeArgPrefix,
11
10
  type ModelInfo,
12
11
  } from "./rv-completions.js";
12
+ import { rvUi } from "./rv-locale.js";
13
+ import { DEFAULT_REVIEW_MODEL_PRIORITIES } from "./rv-model-priorities.js";
13
14
 
14
15
  function model(partial: Partial<ModelInfo> & Pick<ModelInfo, "provider" | "id">): ModelInfo {
15
16
  return {
@@ -72,28 +73,23 @@ describe("extractSignals", () => {
72
73
  assert.equal(sig.targetExt, "ts");
73
74
  });
74
75
 
75
- it("carries primary provider for cross-vendor bonus", () => {
76
+ it("carries primary provider on signals", () => {
76
77
  const sig = extractSignals([], { primaryProvider: "anthropic" });
77
78
  assert.equal(sig.primaryProvider, "anthropic");
78
79
  });
79
80
  });
80
81
 
81
- describe("scoreModelForReview / rankModelsForReview", () => {
82
- it("scores reasoning + cross-vendor + xhigh higher than a cheap non-reasoning model", () => {
83
- const sig = { mode: "challenge", hasTarget: true, targetExt: "md", primaryProvider: "anthropic" };
84
- const opus = scoreModelForReview(MODELS[0], sig);
85
- const gpt = scoreModelForReview(MODELS[1], sig);
86
- const flash = scoreModelForReview(MODELS[2], sig);
87
- assert.ok(gpt.score > opus.score, `gpt (${gpt.score}) should beat opus (${opus.score}) for challenge+cross-vendor`);
88
- assert.ok(opus.score > flash.score, "opus should beat the non-reasoning flash");
89
- assert.ok(flash.score >= 2, "flash still gets context-window points");
82
+ describe("rankModelsForReview", () => {
83
+ it("puts preset code models first when registered", () => {
84
+ const sig = extractSignals(["@src/a.ts"], {});
85
+ const ranked = rankModelsForReview(MODELS, sig, DEFAULT_REVIEW_MODEL_PRIORITIES);
86
+ assert.equal(ranked[0].id, "gpt-5.5");
90
87
  });
91
88
 
92
- it("ranks descending by score", () => {
93
- const sig = { mode: "plan", hasTarget: false, primaryProvider: "anthropic" };
94
- const ranked = rankModelsForReview(MODELS, sig);
95
- assert.equal(ranked[0].id, "gpt-5.5");
96
- assert.equal(ranked[ranked.length - 1].id, "gemini-3-flash");
89
+ it("puts preset plan models first for plan mode", () => {
90
+ const sig = extractSignals(["--mode", "plan"], {});
91
+ const ranked = rankModelsForReview(MODELS, sig, DEFAULT_REVIEW_MODEL_PRIORITIES);
92
+ assert.equal(ranked[0].id, "claude-opus-4-6");
97
93
  });
98
94
  });
99
95
 
@@ -106,13 +102,17 @@ describe("fuzzyMatch", () => {
106
102
  });
107
103
 
108
104
  describe("buildRvCompletions", () => {
109
- const deps = { models: MODELS, primaryProvider: "anthropic" };
105
+ const deps = {
106
+ models: MODELS,
107
+ primaryProvider: "anthropic",
108
+ priorities: DEFAULT_REVIEW_MODEL_PRIORITIES,
109
+ };
110
110
 
111
111
  it("returns dynamic model list after --model, with recommended marker on top items", () => {
112
112
  const items = buildRvCompletions("@x.md --model ", deps);
113
113
  assert.ok(items, "expected items");
114
114
  assert.ok(items!.some((i) => i.label === "openai/gpt-5.5"));
115
- const top = items!.find((i) => i.description?.includes("★ 推荐"));
115
+ const top = items!.find((i) => i.description?.includes("Suggested"));
116
116
  assert.ok(top, "expected at least one recommended item");
117
117
  // value carries the head so applyCompletion replaces the whole arg safely
118
118
  assert.ok(top!.value.startsWith("@x.md --model "));
@@ -169,14 +169,29 @@ describe("buildRvCompletions", () => {
169
169
 
170
170
  it("offers scene templates + models keyword at empty top level", () => {
171
171
  const items = buildRvCompletions("", deps);
172
+ const ui = rvUi("en");
172
173
  assert.ok(items);
173
- assert.ok(items!.some((i) => i.label === "models"));
174
- assert.ok(items!.some((i) => i.label.startsWith("审代码改动")));
175
- // template values end with " @" so the user can keep typing a path
176
- const tmpl = items!.find((i) => i.label.startsWith("审代码改动"))!;
174
+ assert.ok(items!.some((i) => i.label === ui.listModels));
175
+ assert.ok(items!.some((i) => i.label === ui.codePreset));
176
+ const tmpl = items!.find((i) => i.label === ui.codePreset)!;
177
177
  assert.ok(tmpl.value.endsWith(" @"));
178
178
  });
179
179
 
180
+ it("does not duplicate list-models completion at top level", () => {
181
+ for (const locale of ["en", "zh"] as const) {
182
+ for (const prefix of ["", "models", "list", "mod"]) {
183
+ const items = buildRvCompletions(prefix, { ...deps, locale });
184
+ if (!items?.length) continue;
185
+ const ui = rvUi(locale);
186
+ const listModelItems = items.filter((i) => i.label === ui.listModels);
187
+ assert.ok(
188
+ listModelItems.length <= 1,
189
+ `locale=${locale} prefix=${JSON.stringify(prefix)} got ${listModelItems.length} "${ui.listModels}" items`,
190
+ );
191
+ }
192
+ }
193
+ });
194
+
180
195
  it("every returned value preserves the head prefix (整段替换安全)", () => {
181
196
  const cases = ["@a.ts --mode ", "@a.ts --model ", "@a.ts --thinking ", "@a.ts --"];
182
197
  for (const c of cases) {
@@ -202,15 +217,12 @@ describe("buildRvCompletions", () => {
202
217
  }
203
218
  });
204
219
 
205
- it("scene templates omit the thinking colon for non-reasoning models (note #1 fix)", () => {
220
+ it("omits preset scene templates when registry has no preset id match", () => {
206
221
  const nonReasoning = [{ ...MODELS[2], thinkingLevels: [] }] as ModelInfo[];
207
- const items = buildRvCompletions("", { models: nonReasoning });
222
+ const items = buildRvCompletions("", { models: nonReasoning, priorities: DEFAULT_REVIEW_MODEL_PRIORITIES });
208
223
  assert.ok(items);
209
- const tmpl = items!.find((i) => i.label.startsWith("审代码改动"));
210
- assert.ok(tmpl, "expected a scene template even for non-reasoning models");
211
- // Must NOT contain a dangling colon like `gemini-3-flash: @`
212
- assert.doesNotMatch(tmpl!.value, /flash:\s@/);
213
- assert.match(tmpl!.value, /flash @/);
224
+ assert.ok(!items!.some((i) => i.label === rvUi("en").codePreset));
225
+ assert.ok(items!.some((i) => i.label === rvUi("en").listModels));
214
226
  });
215
227
 
216
228
  it("falls back gracefully when models are unavailable (static flags still work)", () => {
@@ -1,16 +1,22 @@
1
1
  /**
2
2
  * Pure, testable /rv argument-completion logic.
3
3
  *
4
- * This module deliberately avoids importing pi types so the tokenizer, state
5
- * machine, and model scorer can be unit-tested with `node:test` and reused
6
- * across hosts. The thin runtime adapter (registry capture + AutocompleteItem
7
- * shaping) lives in `review.ts`.
8
- *
9
- * Completion items are returned "续写式": `value` always carries the already-typed
10
- * head tokens because pi-tui's `applyCompletion` replaces the *entire* argument
11
- * text after the slash command with `item.value`.
4
+ * Avoids pi types so tokenizer + preset ordering can be unit-tested.
5
+ * Completion `value` must include the typed head (pi-tui replaces the whole arg).
12
6
  */
13
7
 
8
+ import { type RvLocale, rvUi } from "./rv-locale.js";
9
+ import { semanticCompletionItems, SEMANTIC_PHRASES } from "./rv-semantic.js";
10
+ import {
11
+ loadReviewModelPriorities,
12
+ primaryPresetForProfile,
13
+ rankModelsWithPresets,
14
+ resolvePresetOrderedModels,
15
+ resolveReviewProfile,
16
+ type ReviewModelPriorities,
17
+ type ReviewProfile,
18
+ } from "./rv-model-priorities.js";
19
+
14
20
  export type AutocompleteItem = {
15
21
  value: string;
16
22
  label: string;
@@ -36,18 +42,18 @@ export const RV_FLAGS = [
36
42
  ] as const;
37
43
 
38
44
  export const MODE_HINTS: Record<string, string> = {
39
- code: "代码 / diff / MR 审核",
40
- plan: "架构 / 计划评审",
41
- challenge: "对抗性方案评审",
45
+ code: "Code / diff / MR review",
46
+ plan: "Architecture or plan review",
47
+ challenge: "Adversarial plan review",
42
48
  };
43
49
 
44
50
  export const FLAG_HINTS: Record<string, string> = {
45
- "--mode": "审核模式 code|plan|challenge",
51
+ "--mode": "Review mode: code | plan | challenge",
46
52
  "--model": "provider/model[:thinking]",
47
- "--thinking": "off|minimal|low|medium|high|xhigh",
48
- "--keep-session": "保留会话,可用 /rv --continue 追问",
49
- "--no-stream": "缓冲输出到结束(非默认)",
50
- "--continue": "继续既有 review 会话",
53
+ "--thinking": "off | minimal | low | medium | high | xhigh",
54
+ "--keep-session": "Keep session for /rv --continue follow-up",
55
+ "--no-stream": "Buffer output until exit (not default)",
56
+ "--continue": "Resume an existing review session",
51
57
  };
52
58
 
53
59
  /** Minimal model metadata we need; converted from the host's Model registry. */
@@ -74,6 +80,31 @@ export interface ReviewSignals {
74
80
  export interface CompletionDeps {
75
81
  models?: ModelInfo[];
76
82
  primaryProvider?: string;
83
+ priorities?: ReviewModelPriorities;
84
+ locale?: RvLocale;
85
+ }
86
+
87
+ function localeOf(deps: CompletionDeps): RvLocale {
88
+ return deps.locale ?? "en";
89
+ }
90
+
91
+ function modeFromSemanticHead(head: string[]): string | undefined {
92
+ const joined = head.join(" ").toLowerCase();
93
+ for (const row of SEMANTIC_PHRASES) {
94
+ if (!row.apply.mode) continue;
95
+ for (const p of row.phrases) {
96
+ if (joined.includes(p.toLowerCase())) return row.apply.mode;
97
+ }
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ function getPriorities(deps: CompletionDeps): ReviewModelPriorities {
103
+ return deps.priorities ?? loadReviewModelPriorities();
104
+ }
105
+
106
+ export function reviewProfileForSignals(sig: ReviewSignals): ReviewProfile {
107
+ return resolveReviewProfile(sig.mode, sig.targetExt);
77
108
  }
78
109
 
79
110
  // ---------------------------------------------------------------------------
@@ -111,12 +142,6 @@ export function tokenizeArgPrefix(prefix: string): TokenizedArgs {
111
142
  // Signal extraction
112
143
  // ---------------------------------------------------------------------------
113
144
 
114
- const CODE_EXTS = new Set([
115
- "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs", "java", "kt",
116
- "c", "cc", "cpp", "h", "hpp", "cs", "php", "swift", "scala", "sh", "sql",
117
- "vue", "svelte",
118
- ]);
119
- const PLAN_EXTS = new Set(["md", "markdown", "txt", "rst", "adoc"]);
120
145
 
121
146
  function extOf(token: string): string | undefined {
122
147
  const cleaned = token.replace(/^['"]|['"]$/g, "").replace(/^@/, "");
@@ -127,7 +152,8 @@ function extOf(token: string): string | undefined {
127
152
  export function extractSignals(head: string[], deps: CompletionDeps): ReviewSignals {
128
153
  const target = head.find((t) => t.startsWith("@"));
129
154
  const modeIdx = head.indexOf("--mode");
130
- const mode = modeIdx >= 0 ? head[modeIdx + 1] : "code";
155
+ const mode =
156
+ (modeIdx >= 0 ? head[modeIdx + 1] : undefined) ?? modeFromSemanticHead(head) ?? "code";
131
157
  return {
132
158
  mode,
133
159
  hasTarget: Boolean(target),
@@ -137,46 +163,27 @@ export function extractSignals(head: string[], deps: CompletionDeps): ReviewSign
137
163
  }
138
164
 
139
165
  // ---------------------------------------------------------------------------
140
- // Model scoring (P2 rule engine — pure, no LLM in the completion hot path)
166
+ // Model ordering: registry + preset priorities (no LLM)
141
167
  // ---------------------------------------------------------------------------
142
168
 
143
- export function scoreModelForReview(
144
- m: ModelInfo,
145
- sig: ReviewSignals,
146
- ): { score: number; reason: string } {
147
- let score = 0;
148
- const reasons: string[] = [];
149
- if (m.reasoning) {
150
- score += 3;
151
- reasons.push("reasoning");
152
- }
153
- if (m.contextWindow >= 200_000) score += 2;
154
- else if (m.contextWindow >= 100_000) score += 1;
155
- if (sig.primaryProvider && m.provider !== sig.primaryProvider) {
156
- score += 1;
157
- reasons.push("跨厂商交叉审核");
158
- }
159
- if ((sig.mode === "plan" || sig.mode === "challenge") && m.thinkingLevels.includes("xhigh")) {
160
- score += 2;
161
- reasons.push("xhigh 思考");
162
- } else if (m.thinkingLevels.includes("high")) {
163
- score += 1;
164
- }
165
- if (sig.targetExt && PLAN_EXTS.has(sig.targetExt) && m.reasoning) {
166
- score += 1;
167
- reasons.push("适合文档评审");
168
- }
169
- return { score, reason: reasons.filter(Boolean).join(" · ") };
170
- }
171
-
172
169
  export function rankModelsForReview(
173
170
  models: ModelInfo[],
174
171
  sig: ReviewSignals,
172
+ priorities?: ReviewModelPriorities,
175
173
  ): ModelInfo[] {
176
- return models
177
- .map((m) => ({ m, s: scoreModelForReview(m, sig).score }))
178
- .sort((a, b) => b.s - a.s)
179
- .map((x) => x.m);
174
+ const profile = reviewProfileForSignals(sig);
175
+ return rankModelsWithPresets(models, profile, priorities ?? loadReviewModelPriorities());
176
+ }
177
+
178
+ function presetDescription(
179
+ presetLabel: string,
180
+ profile: ReviewProfile,
181
+ presetRank: number | undefined,
182
+ locale: RvLocale,
183
+ ): string {
184
+ if (presetRank === undefined) return "";
185
+ const ui = rvUi(locale);
186
+ return `${ui.presetTier(presetRank)} · ${profile} · ${presetLabel}`;
180
187
  }
181
188
 
182
189
  // ---------------------------------------------------------------------------
@@ -234,16 +241,16 @@ function wrap(
234
241
  }));
235
242
  }
236
243
 
237
- function thinkingDescription(level: string, sig: ReviewSignals): string {
244
+ function thinkingDescription(level: string, sig: ReviewSignals, locale: RvLocale): string {
238
245
  const recommended = sig.mode === "code" ? "high" : "xhigh";
239
- const star = level === recommended ? "★ 推荐 " : "";
246
+ const star = level === recommended ? rvUi(locale).thinkingSuggested : "";
240
247
  const map: Record<string, string> = {
241
- off: "不思考(最快)",
242
- minimal: "极简思考",
243
- low: "低强度",
244
- medium: "中强度",
245
- high: "高强度(review 默认)",
246
- xhigh: "极致思考(plan/challenge 推荐)",
248
+ off: "No thinking (fastest)",
249
+ minimal: "Minimal thinking",
250
+ low: "Low effort",
251
+ medium: "Medium effort",
252
+ high: "High effort (common for review)",
253
+ xhigh: "Max effort (plan/challenge)",
247
254
  };
248
255
  return `${star}${map[level] ?? level}`;
249
256
  }
@@ -259,22 +266,25 @@ function modelItems(
259
266
  sig: ReviewSignals,
260
267
  headPrefix: string,
261
268
  tail: string,
269
+ deps: CompletionDeps,
262
270
  ): AutocompleteItem[] | null {
263
- const ranked = rankModelsForReview(models, sig);
264
- const items = ranked.map((m, i) => {
265
- const { score, reason } = scoreModelForReview(m, sig);
266
- const recommended = i < 3 && score > 0;
267
- const tag =
268
- sig.mode === "plan" || sig.mode === "challenge" ? "plan/challenge" : "review";
271
+ const priorities = getPriorities(deps);
272
+ const profile = reviewProfileForSignals(sig);
273
+ const presetRows = resolvePresetOrderedModels(models, profile, priorities);
274
+ const presetByLabel = new Map(presetRows.map((p) => [p.model.label, p]));
275
+ const ranked = rankModelsWithPresets(models, profile, priorities);
276
+ const items = ranked.map((m) => {
277
+ const preset = presetByLabel.get(m.label);
278
+ const tag = profile;
269
279
  return {
270
280
  value: `${headPrefix}${m.label}`,
271
281
  label: m.label,
272
282
  description: [
273
- recommended ? "★ 推荐" : "",
283
+ preset ? presetDescription(preset.presetLabel, profile, preset.presetRank, localeOf(deps)) : "",
274
284
  tag,
275
285
  m.reasoning ? "reasoning" : "",
276
286
  contextLabel(m.contextWindow),
277
- reason,
287
+ preset?.thinking ? `preset thinking ${preset.thinking}` : "",
278
288
  ]
279
289
  .filter(Boolean)
280
290
  .join(" · "),
@@ -301,6 +311,7 @@ function thinkingSuffixItems(
301
311
  models: ModelInfo[],
302
312
  headPrefix: string,
303
313
  sig: ReviewSignals,
314
+ deps: CompletionDeps,
304
315
  ): AutocompleteItem[] | null {
305
316
  const colonIdx = tail.lastIndexOf(":");
306
317
  const modelPart = tail.slice(0, colonIdx);
@@ -319,40 +330,64 @@ function thinkingSuffixItems(
319
330
  return filtered.map((level) => ({
320
331
  value: `${headPrefix}${modelPart}:${level}`,
321
332
  label: level,
322
- description: thinkingDescription(level, sig),
333
+ description: thinkingDescription(level, sig, localeOf(deps)),
323
334
  }));
324
335
  }
325
336
 
337
+ function thinkingSuffixForModel(m: ModelInfo, want?: string): string {
338
+ const lvl =
339
+ want && m.thinkingLevels.includes(want)
340
+ ? want
341
+ : want && m.thinkingLevels.includes("high")
342
+ ? "high"
343
+ : m.thinkingLevels.includes("xhigh")
344
+ ? "xhigh"
345
+ : m.thinkingLevels.includes("high")
346
+ ? "high"
347
+ : (m.thinkingLevels[0] ?? "");
348
+ return lvl ? `:${lvl}` : "";
349
+ }
350
+
326
351
  function sceneTemplates(
327
- top: ModelInfo | undefined,
352
+ models: ModelInfo[],
353
+ sig: ReviewSignals,
328
354
  headPrefix: string,
329
355
  tail: string,
356
+ deps: CompletionDeps,
330
357
  ): AutocompleteItem[] {
331
- if (!top) return [];
332
- const pref = (m: ModelInfo, want: string): string => {
333
- if (m.thinkingLevels.includes(want)) return want;
334
- if (m.thinkingLevels.includes("high")) return "high";
335
- return m.thinkingLevels[0] ?? "";
336
- };
337
- const suffix = (want: string): string => {
338
- const lvl = pref(top, want);
339
- return lvl ? `:${lvl}` : "";
340
- };
358
+ const priorities = getPriorities(deps);
359
+ const codePrimary = primaryPresetForProfile(models, "code", priorities);
360
+ const planPrimary = primaryPresetForProfile(models, "plan", priorities);
361
+ const frontendPrimary = primaryPresetForProfile(models, "frontend", priorities);
362
+
363
+ const ui = rvUi(localeOf(deps));
364
+ const templates: { cmd: string; label: string }[] = [];
365
+ if (codePrimary) {
366
+ const m = codePrimary.model;
367
+ templates.push({
368
+ cmd: `${ui.modeCode} --model ${m.label}${thinkingSuffixForModel(m, codePrimary.thinking ?? "xhigh")} @`,
369
+ label: ui.codePreset,
370
+ });
371
+ }
372
+ if (frontendPrimary) {
373
+ const m = frontendPrimary.model;
374
+ templates.push({
375
+ cmd: `${ui.modeCode} --model ${m.label}${thinkingSuffixForModel(m, frontendPrimary.thinking)} @`,
376
+ label: ui.frontendPreset,
377
+ });
378
+ }
379
+ if (planPrimary) {
380
+ const m = planPrimary.model;
381
+ templates.push({
382
+ cmd: `${ui.modePlan} --model ${m.label}${thinkingSuffixForModel(m, planPrimary.thinking ?? "high")} @`,
383
+ label: ui.planPreset,
384
+ });
385
+ templates.push({
386
+ cmd: `${ui.modeChallenge} ${ui.keepSession} --model ${m.label}${thinkingSuffixForModel(m, "xhigh")} @`,
387
+ label: ui.challengePreset,
388
+ });
389
+ }
341
390
 
342
- const templates = [
343
- {
344
- cmd: `--mode code --model ${top.label}${suffix("high")} @`,
345
- label: "审代码改动(推荐配置)",
346
- },
347
- {
348
- cmd: `--mode plan --model ${top.label}${suffix("high")} @`,
349
- label: "架构 / 计划评审",
350
- },
351
- {
352
- cmd: `--mode challenge --keep-session --model ${top.label}${suffix("xhigh")} @`,
353
- label: "对抗性方案评审(可追问)",
354
- },
355
- ];
356
391
  return templates
357
392
  .filter((t) => fuzzyMatch(t.label, tail) || t.cmd.includes(tail))
358
393
  .map((t) => ({
@@ -382,21 +417,31 @@ function topLevelCompletions(
382
417
  const items: AutocompleteItem[] = [];
383
418
 
384
419
  // Scene templates (only at top level, before any target/mode chosen is fine).
385
- const ranked = deps.models?.length ? rankModelsForReview(deps.models, sig) : [];
386
- items.push(...sceneTemplates(ranked[0], headPrefix, tail));
420
+ if (deps.models?.length) {
421
+ items.push(...sceneTemplates(deps.models, sig, headPrefix, tail, deps));
422
+ }
423
+
424
+ items.push(...semanticCompletionItems(localeOf(deps), tail, headPrefix, head));
387
425
 
388
- // `models` keyword.
389
- if (fuzzyMatch("models", tail) || "models".startsWith(tail)) {
426
+ const ui = rvUi(localeOf(deps));
427
+ const hasListModelsItem = items.some((i) => i.label === ui.listModels);
428
+ if (
429
+ !hasListModelsItem &&
430
+ (fuzzyMatch(ui.modelsKeyword, tail) ||
431
+ ui.modelsKeyword.startsWith(tail) ||
432
+ fuzzyMatch("models", tail))
433
+ ) {
390
434
  items.push({
391
- value: `${headPrefix}models`,
392
- label: "models",
393
- description: "仅列出 pi-review 可用模型",
435
+ value: `${headPrefix}${ui.listModels}`,
436
+ label: ui.listModels,
437
+ description: ui.listModelsDesc,
394
438
  });
395
439
  }
396
440
 
397
- // Flags.
398
- const flags = flagItems(head, tail, headPrefix);
399
- if (flags) items.push(...flags);
441
+ if (tail.startsWith("--")) {
442
+ const flags = flagItems(head, tail, headPrefix);
443
+ if (flags) items.push(...flags);
444
+ }
400
445
 
401
446
  return items.length ? items : null;
402
447
  }
@@ -418,10 +463,10 @@ export function buildRvCompletions(
418
463
  if (prev === "--model") {
419
464
  const colonIdx = tail.lastIndexOf(":");
420
465
  if (colonIdx !== -1 && tail.slice(0, colonIdx).includes("/")) {
421
- const items = thinkingSuffixItems(tail, deps.models ?? [], headPrefix, sig);
466
+ const items = thinkingSuffixItems(tail, deps.models ?? [], headPrefix, sig, deps);
422
467
  return items;
423
468
  }
424
- return modelItems(deps.models ?? [], sig, headPrefix, tail);
469
+ return modelItems(deps.models ?? [], sig, headPrefix, tail, deps);
425
470
  }
426
471
 
427
472
  // 2) --mode value position.
@@ -0,0 +1,17 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { detectRvLocale } from "./rv-locale.js";
4
+
5
+ describe("detectRvLocale", () => {
6
+ it("returns zh when recent messages are mostly Chinese", () => {
7
+ assert.equal(detectRvLocale(["帮我看一下这个方案", "好的"]), "zh");
8
+ });
9
+
10
+ it("returns en for English-only samples", () => {
11
+ assert.equal(detectRvLocale(["Please review this diff", "Thanks"]), "en");
12
+ });
13
+
14
+ it("defaults to en when empty", () => {
15
+ assert.equal(detectRvLocale([]), "en");
16
+ });
17
+ });