@zephyrdeng/pi-review 0.7.1 → 0.8.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 +37 -8
- package/README.zh-CN.md +31 -5
- package/dist/args.d.ts +4 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +61 -4
- package/dist/args.js.map +1 -1
- package/dist/args.test.js +62 -1
- package/dist/args.test.js.map +1 -1
- package/dist/child-process.d.ts +3 -0
- package/dist/child-process.d.ts.map +1 -1
- package/dist/child-process.js +27 -5
- package/dist/child-process.js.map +1 -1
- package/dist/child-process.test.js +42 -1
- package/dist/child-process.test.js.map +1 -1
- package/dist/cli-exit.test.js +14 -5
- package/dist/cli-exit.test.js.map +1 -1
- package/dist/cli.js +8 -3
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/install-args.test.js +1 -1
- package/dist/install-args.test.js.map +1 -1
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +2 -3
- package/dist/install.js.map +1 -1
- package/dist/json-events.d.ts +6 -0
- package/dist/json-events.d.ts.map +1 -1
- package/dist/json-events.js +48 -6
- package/dist/json-events.js.map +1 -1
- package/dist/json-events.test.js +20 -1
- package/dist/json-events.test.js.map +1 -1
- package/dist/loop.d.ts +28 -2
- package/dist/loop.d.ts.map +1 -1
- package/dist/loop.js +45 -3
- package/dist/loop.js.map +1 -1
- package/dist/loop.test.js +24 -1
- package/dist/loop.test.js.map +1 -1
- package/dist/matcher.test.js +1 -1
- package/dist/matcher.test.js.map +1 -1
- package/dist/meta-footer.d.ts +4 -8
- package/dist/meta-footer.d.ts.map +1 -1
- package/dist/meta-footer.js +19 -5
- package/dist/meta-footer.js.map +1 -1
- package/dist/meta-footer.test.js +11 -4
- package/dist/meta-footer.test.js.map +1 -1
- package/dist/panel-aggregate.test.js +1 -1
- package/dist/panel-aggregate.test.js.map +1 -1
- package/dist/panel-cancellation.test.js +33 -2
- package/dist/panel-cancellation.test.js.map +1 -1
- package/dist/panel-cli-ui.test.d.ts +2 -0
- package/dist/panel-cli-ui.test.d.ts.map +1 -0
- package/dist/panel-cli-ui.test.js +91 -0
- package/dist/panel-cli-ui.test.js.map +1 -0
- package/dist/panel-cli.test.js +37 -39
- package/dist/panel-cli.test.js.map +1 -1
- package/dist/panel-config.d.ts +38 -0
- package/dist/panel-config.d.ts.map +1 -1
- package/dist/panel-config.js +135 -2
- package/dist/panel-config.js.map +1 -1
- package/dist/panel-config.test.js +79 -2
- package/dist/panel-config.test.js.map +1 -1
- package/dist/panel-loop.test.js +1 -1
- package/dist/panel-loop.test.js.map +1 -1
- package/dist/panel-tools.test.js +1 -1
- package/dist/panel-tools.test.js.map +1 -1
- package/dist/panel-ui.d.ts +20 -0
- package/dist/panel-ui.d.ts.map +1 -0
- package/dist/panel-ui.js +84 -0
- package/dist/panel-ui.js.map +1 -0
- package/dist/panel-ui.test.d.ts +2 -0
- package/dist/panel-ui.test.d.ts.map +1 -0
- package/dist/panel-ui.test.js +122 -0
- package/dist/panel-ui.test.js.map +1 -0
- package/dist/panel-view.d.ts +1 -0
- package/dist/panel-view.d.ts.map +1 -1
- package/dist/panel-view.js +40 -6
- package/dist/panel-view.js.map +1 -1
- package/dist/panel-view.test.js +68 -1
- package/dist/panel-view.test.js.map +1 -1
- package/dist/panel.d.ts +11 -1
- package/dist/panel.d.ts.map +1 -1
- package/dist/panel.js +67 -21
- package/dist/panel.js.map +1 -1
- package/dist/prompt.d.ts +2 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +34 -3
- package/dist/prompt.js.map +1 -1
- package/dist/prompt.test.js +21 -2
- package/dist/prompt.test.js.map +1 -1
- package/dist/review-events.test.js +1 -1
- package/dist/review-events.test.js.map +1 -1
- package/dist/review-result.test.js +1 -1
- package/dist/review-result.test.js.map +1 -1
- package/dist/review.js +3 -3
- package/dist/review.js.map +1 -1
- package/dist/review.meta.test.js +1 -1
- package/dist/review.meta.test.js.map +1 -1
- package/dist/skill-protocol.test.js +1 -1
- package/dist/skill-protocol.test.js.map +1 -1
- package/dist/skill-update.test.d.ts +2 -0
- package/dist/skill-update.test.d.ts.map +1 -0
- package/dist/skill-update.test.js +23 -0
- package/dist/skill-update.test.js.map +1 -0
- package/dist/skill.d.ts +22 -0
- package/dist/skill.d.ts.map +1 -1
- package/dist/skill.js +109 -22
- package/dist/skill.js.map +1 -1
- package/dist/types.d.ts +23 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/ui-client.d.ts +13 -0
- package/dist/ui-client.d.ts.map +1 -0
- package/dist/ui-client.js +161 -0
- package/dist/ui-client.js.map +1 -0
- package/dist/ui-client.test.d.ts +2 -0
- package/dist/ui-client.test.d.ts.map +1 -0
- package/dist/ui-client.test.js +48 -0
- package/dist/ui-client.test.js.map +1 -0
- package/dist/ui-html.d.ts +7 -0
- package/dist/ui-html.d.ts.map +1 -0
- package/dist/ui-html.js +52 -0
- package/dist/ui-html.js.map +1 -0
- package/dist/ui-html.test.d.ts +2 -0
- package/dist/ui-html.test.d.ts.map +1 -0
- package/dist/ui-html.test.js +26 -0
- package/dist/ui-html.test.js.map +1 -0
- package/dist/ui-server-main.d.ts +3 -0
- package/dist/ui-server-main.d.ts.map +1 -0
- package/dist/ui-server-main.js +128 -0
- package/dist/ui-server-main.js.map +1 -0
- package/dist/ui-server-main.test.d.ts +2 -0
- package/dist/ui-server-main.test.d.ts.map +1 -0
- package/dist/ui-server-main.test.js +123 -0
- package/dist/ui-server-main.test.js.map +1 -0
- package/dist/ui-server.d.ts +42 -0
- package/dist/ui-server.d.ts.map +1 -0
- package/dist/ui-server.js +233 -0
- package/dist/ui-server.js.map +1 -0
- package/dist/ui-server.test.d.ts +2 -0
- package/dist/ui-server.test.d.ts.map +1 -0
- package/dist/ui-server.test.js +236 -0
- package/dist/ui-server.test.js.map +1 -0
- package/dist/update.d.ts +2 -0
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +80 -20
- package/dist/update.js.map +1 -1
- package/dist/update.test.d.ts +2 -0
- package/dist/update.test.d.ts.map +1 -0
- package/dist/update.test.js +17 -0
- package/dist/update.test.js.map +1 -0
- package/extensions/panel-tool.test.ts +366 -8
- package/extensions/panel-tool.ts +289 -68
- package/extensions/review.test.ts +79 -0
- package/extensions/review.ts +188 -44
- package/extensions/rv-completions.test.ts +177 -18
- package/extensions/rv-completions.ts +490 -60
- package/extensions/rv-interactive.test.ts +562 -0
- package/extensions/rv-interactive.ts +616 -0
- package/extensions/rv-locale.test.ts +12 -4
- package/extensions/rv-locale.ts +44 -11
- package/extensions/rv-model-picker.test.ts +172 -0
- package/extensions/rv-model-picker.ts +313 -0
- package/extensions/rv-model-priorities.test.ts +29 -9
- package/extensions/rv-model-priorities.ts +23 -8
- package/extensions/rv-prompts.test.ts +274 -2
- package/extensions/rv-prompts.ts +479 -33
- package/extensions/rv-resolve.test.ts +189 -0
- package/extensions/rv-resolve.ts +313 -0
- package/extensions/rv-semantic.test.ts +1 -1
- package/extensions/rv-semantic.ts +38 -4
- package/package.json +7 -4
- package/resources/rv-model-priorities.json +18 -9
- package/skills/pi-review/SKILL.md +84 -60
- package/skills/pi-review/references/codex-tools.md +18 -11
- package/skills/pi-review/references/model-selection.md +49 -28
- package/dist/extensions/review.d.ts +0 -2
- package/dist/extensions/review.js +0 -22
- package/dist/extensions/rv-prompts.d.ts +0 -19
- package/dist/extensions/rv-prompts.js +0 -132
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive /rv and /rv-loop wizard using Pi dialog APIs (select/input/confirm).
|
|
3
|
+
* Pure orchestration over injected UI so it is unit-testable without a TUI.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ModelInfo } from "./rv-completions.js";
|
|
7
|
+
import type { RvLocale } from "./rv-locale.js";
|
|
8
|
+
import { rvUi } from "./rv-locale.js";
|
|
9
|
+
import {
|
|
10
|
+
MODEL_PICKER_SKIP,
|
|
11
|
+
type ModelPickerResult,
|
|
12
|
+
} from "./rv-model-picker.js";
|
|
13
|
+
import {
|
|
14
|
+
RV_CLEAN_GOAL,
|
|
15
|
+
RV_CONSENSUS_POLICIES,
|
|
16
|
+
RV_LOOP_DEFAULT_MAX_ROUNDS,
|
|
17
|
+
RV_LOOP_UNTIL_CLEAN_DEFAULT_MAX_ROUNDS,
|
|
18
|
+
type RvConsensusPolicy,
|
|
19
|
+
type RvParsed,
|
|
20
|
+
type RvStrategy,
|
|
21
|
+
} from "./rv-prompts.js";
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_REVIEW_MODEL_PRIORITIES,
|
|
24
|
+
loadReviewModelPriorities,
|
|
25
|
+
rankModelsWithPresets,
|
|
26
|
+
resolveReviewProfile,
|
|
27
|
+
type ReviewProfile,
|
|
28
|
+
} from "./rv-model-priorities.js";
|
|
29
|
+
|
|
30
|
+
export type CustomModelPickerInput = {
|
|
31
|
+
/** Models already ranked for the active mode (presets first). */
|
|
32
|
+
ranked: ModelInfo[];
|
|
33
|
+
locale: RvLocale;
|
|
34
|
+
allowSkip: boolean;
|
|
35
|
+
title: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type InteractiveUi = {
|
|
39
|
+
select: (title: string, options: string[]) => Promise<string | undefined>;
|
|
40
|
+
input: (title: string, placeholder?: string) => Promise<string | undefined>;
|
|
41
|
+
confirm: (title: string, message: string) => Promise<boolean>;
|
|
42
|
+
notify: (message: string, type?: "info" | "warning" | "error") => void;
|
|
43
|
+
/** Inline searchable model picker (TUI only). When present, the wizard uses
|
|
44
|
+
* it instead of the select-based flow. Returns a label, `MODEL_PICKER_SKIP`,
|
|
45
|
+
* or `undefined` (cancel). */
|
|
46
|
+
customModelPicker?: (input: CustomModelPickerInput) => Promise<ModelPickerResult>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type InteractiveWizardInput = {
|
|
50
|
+
strategy: Exclude<RvStrategy, "models">;
|
|
51
|
+
/** Already-parsed partial from the command line (flags + optional target). */
|
|
52
|
+
seed: RvParsed;
|
|
53
|
+
models: ModelInfo[];
|
|
54
|
+
locale: RvLocale;
|
|
55
|
+
primaryProvider?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const CANCEL = Symbol("cancel");
|
|
59
|
+
|
|
60
|
+
function profileFor(mode: string): ReviewProfile {
|
|
61
|
+
return resolveReviewProfile(mode);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function rankedModelLabels(models: ModelInfo[], mode: string): string[] {
|
|
65
|
+
if (models.length === 0) return [];
|
|
66
|
+
const profile = profileFor(mode);
|
|
67
|
+
const priorities = loadReviewModelPriorities();
|
|
68
|
+
return rankModelsWithPresets(models, profile, priorities).map((m) => m.label);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function modelByLabel(models: ModelInfo[], label: string): ModelInfo | undefined {
|
|
72
|
+
return models.find((m) => m.label === label);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function thinkingOptions(model: ModelInfo | undefined, locale: RvLocale): string[] {
|
|
76
|
+
const levels = model?.thinkingLevels?.length ? model.thinkingLevels : ["high", "xhigh", "medium", "low", "off"];
|
|
77
|
+
const skip = locale === "zh" ? "跳过(默认)" : "Skip (default)";
|
|
78
|
+
return [skip, ...levels];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stripSkip(choice: string | undefined, locale: RvLocale): string | undefined {
|
|
82
|
+
if (!choice) return undefined;
|
|
83
|
+
if (choice.startsWith("Skip") || choice.startsWith("跳过")) return undefined;
|
|
84
|
+
return choice;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function mustSelect(ui: InteractiveUi, title: string, options: string[]): Promise<string | typeof CANCEL> {
|
|
88
|
+
if (options.length === 0) return CANCEL;
|
|
89
|
+
const choice = await ui.select(title, options);
|
|
90
|
+
return choice ?? CANCEL;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** At or below this model count, the wizard shows a simple flat ranked list.
|
|
94
|
+
* Above it, the flat list gets hard to navigate, so the wizard first offers a
|
|
95
|
+
* provider browser and a search box (Pi's `select` dialog has no built-in
|
|
96
|
+
* search or grouping, so we compose it from `select` + `input`). */
|
|
97
|
+
const MODEL_PICKER_THRESHOLD = 8;
|
|
98
|
+
|
|
99
|
+
/** Case-insensitive, separator-insensitive match used by the search path.
|
|
100
|
+
* Mirrors the completion matcher so "gpt55", "gpt-5.5", "openai/gpt-5.5" all hit. */
|
|
101
|
+
function modelMatchesQuery(m: ModelInfo, query: string): boolean {
|
|
102
|
+
const q = query.toLowerCase();
|
|
103
|
+
const label = m.label.toLowerCase();
|
|
104
|
+
const id = m.id.toLowerCase();
|
|
105
|
+
const provider = m.provider.toLowerCase();
|
|
106
|
+
const labelKey = label.replace(/[^a-z0-9/]+/g, "");
|
|
107
|
+
const idKey = id.replace(/[^a-z0-9]+/g, "");
|
|
108
|
+
const qKey = q.replace(/[^a-z0-9/]+/g, "");
|
|
109
|
+
return (
|
|
110
|
+
label.startsWith(q) ||
|
|
111
|
+
label.includes(q) ||
|
|
112
|
+
id.startsWith(q) ||
|
|
113
|
+
provider.startsWith(q) ||
|
|
114
|
+
provider.includes(q) ||
|
|
115
|
+
labelKey.includes(qKey) ||
|
|
116
|
+
idKey.includes(qKey.replace(/\//g, ""))
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function rankedModels(models: ModelInfo[], mode: string): ModelInfo[] {
|
|
121
|
+
if (models.length === 0) return [];
|
|
122
|
+
return rankModelsWithPresets(models, profileFor(mode), loadReviewModelPriorities());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Pick a model label through dialogs.
|
|
127
|
+
* @returns the chosen label; `CANCEL` if the user escapes; `undefined` if they pick skip.
|
|
128
|
+
*
|
|
129
|
+
* With few models this is a flat ranked list (optionally with a Skip entry).
|
|
130
|
+
* With many models it first asks how to pick — browse by provider, search, or
|
|
131
|
+
* the full ranked list — so a long catalog stays navigable without scrolling
|
|
132
|
+
* dozens of rows.
|
|
133
|
+
*/
|
|
134
|
+
async function pickModelLabel(
|
|
135
|
+
ui: InteractiveUi,
|
|
136
|
+
models: ModelInfo[],
|
|
137
|
+
mode: string,
|
|
138
|
+
locale: RvLocale,
|
|
139
|
+
title: string,
|
|
140
|
+
allowSkip: boolean,
|
|
141
|
+
): Promise<string | typeof CANCEL | undefined> {
|
|
142
|
+
const zh = locale === "zh";
|
|
143
|
+
const ranked = rankedModels(models, mode);
|
|
144
|
+
if (ranked.length === 0) return CANCEL;
|
|
145
|
+
|
|
146
|
+
// Inline searchable picker (TUI only) — one box, live fuzzy filter, Tab to
|
|
147
|
+
// cycle provider scope. Preferred over the select-based flow whenever the
|
|
148
|
+
// host provides it; print/RPC modes have no `custom` and fall through.
|
|
149
|
+
if (ui.customModelPicker) {
|
|
150
|
+
const result = await ui.customModelPicker({ ranked, locale, allowSkip, title });
|
|
151
|
+
if (result === undefined) return CANCEL; // user cancelled (Esc)
|
|
152
|
+
if (result === MODEL_PICKER_SKIP) return undefined; // skip (Pi default)
|
|
153
|
+
return result; // chosen label
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const labels = ranked.map((m) => m.label);
|
|
157
|
+
const skipOption = allowSkip ? [zh ? "跳过(用 Pi 默认)" : "Skip (Pi default)"] : [];
|
|
158
|
+
|
|
159
|
+
// Simple case: small catalog — flat ranked list, optionally with skip.
|
|
160
|
+
if (ranked.length <= MODEL_PICKER_THRESHOLD) {
|
|
161
|
+
const pick = await mustSelect(ui, title, [...skipOption, ...labels]);
|
|
162
|
+
if (pick === CANCEL) return CANCEL;
|
|
163
|
+
return stripSkip(pick, locale);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Many models — offer provider browse / search / full ranked list.
|
|
167
|
+
const methodOptions = [
|
|
168
|
+
zh ? "按 provider 浏览(↑↓ 切换 provider,回车进入模型)" : "Browse by provider (arrows switch provider, enter drills in)",
|
|
169
|
+
zh ? "搜索模型(输入 provider 或 model 关键词)" : "Search models (type a provider or model keyword)",
|
|
170
|
+
zh ? "按推荐排序查看全部" : "Ranked list (all models)",
|
|
171
|
+
...skipOption,
|
|
172
|
+
];
|
|
173
|
+
const method = await mustSelect(ui, title, methodOptions);
|
|
174
|
+
if (method === CANCEL) return CANCEL;
|
|
175
|
+
if (allowSkip && stripSkip(method, locale) === undefined) return undefined;
|
|
176
|
+
|
|
177
|
+
// Dispatch by stable prefix — the search option text mentions "provider" as a
|
|
178
|
+
// hint, so check Search before the provider branch to avoid misrouting.
|
|
179
|
+
if (method.startsWith("Search") || method.startsWith("搜索")) {
|
|
180
|
+
return searchModel(ui, ranked, locale, title);
|
|
181
|
+
}
|
|
182
|
+
if (method.startsWith("Browse") || method.startsWith("按 provider")) {
|
|
183
|
+
return browseByProvider(ui, ranked, locale, title);
|
|
184
|
+
}
|
|
185
|
+
// Ranked list (all) — show every model; Escape to cancel.
|
|
186
|
+
const allPick = await mustSelect(ui, title, labels);
|
|
187
|
+
if (allPick === CANCEL) return CANCEL;
|
|
188
|
+
return allPick;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Provider browser: pick a provider, then a model under it. The model list
|
|
192
|
+
* includes a "← Switch provider" entry so the user can jump back and pick an
|
|
193
|
+
* adjacent provider (the closest affordance to left/right switching that the
|
|
194
|
+
* flat `select` dialog supports). The previously chosen provider is marked
|
|
195
|
+
* with ▸ so the user sees where they were. */
|
|
196
|
+
async function browseByProvider(
|
|
197
|
+
ui: InteractiveUi,
|
|
198
|
+
ranked: ModelInfo[],
|
|
199
|
+
locale: RvLocale,
|
|
200
|
+
title: string,
|
|
201
|
+
): Promise<string | typeof CANCEL> {
|
|
202
|
+
const zh = locale === "zh";
|
|
203
|
+
const byProvider = new Map<string, ModelInfo[]>();
|
|
204
|
+
for (const m of ranked) {
|
|
205
|
+
const list = byProvider.get(m.provider) ?? [];
|
|
206
|
+
list.push(m);
|
|
207
|
+
byProvider.set(m.provider, list);
|
|
208
|
+
}
|
|
209
|
+
// Biggest vendors first, then alphabetical — keeps the common providers near the top.
|
|
210
|
+
const providers = [...byProvider.entries()].sort(
|
|
211
|
+
(a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
let lastIdx = 0;
|
|
215
|
+
for (;;) {
|
|
216
|
+
const providerOptions = providers.map(([p, list], i) => {
|
|
217
|
+
const marker = i === lastIdx ? "▸ " : " ";
|
|
218
|
+
return `${marker}${p} (${list.length})`;
|
|
219
|
+
});
|
|
220
|
+
const provPick = await mustSelect(
|
|
221
|
+
ui,
|
|
222
|
+
zh ? `${title} · 选择 provider(↑↓ 切换,回车进入)` : `${title} · pick provider (arrows to switch)`,
|
|
223
|
+
providerOptions,
|
|
224
|
+
);
|
|
225
|
+
if (provPick === CANCEL) return CANCEL;
|
|
226
|
+
const idx = Math.max(0, providerOptions.indexOf(provPick));
|
|
227
|
+
lastIdx = idx;
|
|
228
|
+
const provider = providers[idx][0];
|
|
229
|
+
const modelLabels = providers[idx][1].map((m) => m.label);
|
|
230
|
+
|
|
231
|
+
const backEntry = zh ? "← 切换 provider" : "← Switch provider";
|
|
232
|
+
const modelPick = await mustSelect(
|
|
233
|
+
ui,
|
|
234
|
+
zh ? `${title} · ${provider}(共 ${modelLabels.length} 个)` : `${title} · ${provider} (${modelLabels.length} models)`,
|
|
235
|
+
[backEntry, ...modelLabels],
|
|
236
|
+
);
|
|
237
|
+
if (modelPick === CANCEL) return CANCEL;
|
|
238
|
+
if (modelPick === backEntry) continue;
|
|
239
|
+
return modelPick;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Search flow: type a keyword, then pick from the filtered list. The filtered
|
|
244
|
+
* list includes a "← Search again" entry; empty matches notify and re-prompt. */
|
|
245
|
+
async function searchModel(
|
|
246
|
+
ui: InteractiveUi,
|
|
247
|
+
ranked: ModelInfo[],
|
|
248
|
+
locale: RvLocale,
|
|
249
|
+
title: string,
|
|
250
|
+
): Promise<string | typeof CANCEL> {
|
|
251
|
+
const zh = locale === "zh";
|
|
252
|
+
for (;;) {
|
|
253
|
+
const q = await ui.input(
|
|
254
|
+
zh
|
|
255
|
+
? `${title} · 搜索(provider 或 model 关键词,回车=显示全部)`
|
|
256
|
+
: `${title} · search (provider or model keyword; enter = all)`,
|
|
257
|
+
zh ? "例如:claude / gpt / anthropic" : "e.g. claude / gpt / anthropic",
|
|
258
|
+
);
|
|
259
|
+
if (q === undefined) return CANCEL; // user escaped the input
|
|
260
|
+
const query = q.trim().toLowerCase();
|
|
261
|
+
const filtered = query ? ranked.filter((m) => modelMatchesQuery(m, query)) : ranked;
|
|
262
|
+
if (filtered.length === 0) {
|
|
263
|
+
ui.notify(
|
|
264
|
+
zh ? `没有匹配 "${query}" 的模型,请换个关键词` : `No models match "${query}"; try another keyword`,
|
|
265
|
+
"warning",
|
|
266
|
+
);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const backEntry = zh ? "← 重新搜索" : "← Search again";
|
|
270
|
+
const labels = filtered.map((m) => m.label);
|
|
271
|
+
const pick = await mustSelect(
|
|
272
|
+
ui,
|
|
273
|
+
zh ? `${title} · 搜索结果(${labels.length} 个)` : `${title} · results (${labels.length})`,
|
|
274
|
+
[backEntry, ...labels],
|
|
275
|
+
);
|
|
276
|
+
if (pick === CANCEL) return CANCEL;
|
|
277
|
+
if (pick === backEntry) continue;
|
|
278
|
+
return pick;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Build a fully populated RvParsed via dialogs.
|
|
284
|
+
* Returns undefined if the user cancels any required step.
|
|
285
|
+
*/
|
|
286
|
+
export async function runRvInteractiveWizard(
|
|
287
|
+
ui: InteractiveUi,
|
|
288
|
+
input: InteractiveWizardInput,
|
|
289
|
+
): Promise<RvParsed | undefined> {
|
|
290
|
+
const { strategy, seed, models, locale } = input;
|
|
291
|
+
const zh = locale === "zh";
|
|
292
|
+
const uiText = rvUi(locale);
|
|
293
|
+
|
|
294
|
+
// 1) Mode. This drives the model profile, so choose it before target/model input.
|
|
295
|
+
let mode = seed.mode || "code";
|
|
296
|
+
if (!seed.mode || seed.mode === "code") {
|
|
297
|
+
const modeChoice = await mustSelect(
|
|
298
|
+
ui,
|
|
299
|
+
zh ? "审查模式" : "Review mode",
|
|
300
|
+
[
|
|
301
|
+
`${uiText.modeCode} (code)`,
|
|
302
|
+
`${uiText.modePlan} (plan)`,
|
|
303
|
+
`${uiText.modeChallenge} (challenge)`,
|
|
304
|
+
],
|
|
305
|
+
);
|
|
306
|
+
if (modeChoice === CANCEL) return undefined;
|
|
307
|
+
if (modeChoice.includes("(plan)")) mode = "plan";
|
|
308
|
+
else if (modeChoice.includes("(challenge)")) mode = "challenge";
|
|
309
|
+
else mode = "code";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 2) Target. Collect it after mode so the flow starts with review intent.
|
|
313
|
+
let target = seed.target.trim();
|
|
314
|
+
if (!target) {
|
|
315
|
+
const entered = await ui.input(
|
|
316
|
+
zh ? "审查目标(自然语言或 @路径)" : "Review target (natural language or @path)",
|
|
317
|
+
zh ? "例如:@src 或 review the auth changes" : "e.g. @src or review the auth changes",
|
|
318
|
+
);
|
|
319
|
+
if (!entered?.trim()) {
|
|
320
|
+
ui.notify(zh ? "已取消:需要审查目标" : "Cancelled: target required", "warning");
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
target = entered.trim();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 3) Loop goal + hard budget — always ask for /rv-loop so the stop condition is explicit.
|
|
327
|
+
let until = seed.until;
|
|
328
|
+
let maxRounds = seed.maxRounds;
|
|
329
|
+
if (strategy === "loop") {
|
|
330
|
+
const goal = await mustSelect(
|
|
331
|
+
ui,
|
|
332
|
+
zh ? "Loop 目标" : "Loop goal",
|
|
333
|
+
[
|
|
334
|
+
zh
|
|
335
|
+
? "单轮门禁(修完再手动 /rv-loop)· 推荐"
|
|
336
|
+
: "Single gate (fix then re-run /rv-loop) · recommended",
|
|
337
|
+
zh
|
|
338
|
+
? "until clean · 审→修→再审直到 clean(有硬预算)"
|
|
339
|
+
: "until clean · review→fix→re-review until clean (hard budget)",
|
|
340
|
+
],
|
|
341
|
+
);
|
|
342
|
+
if (goal === CANCEL) return undefined;
|
|
343
|
+
if (goal.includes("until clean")) {
|
|
344
|
+
until = "clean";
|
|
345
|
+
const current = maxRounds ?? RV_LOOP_UNTIL_CLEAN_DEFAULT_MAX_ROUNDS;
|
|
346
|
+
const budget = await mustSelect(
|
|
347
|
+
ui,
|
|
348
|
+
zh
|
|
349
|
+
? `until-clean 硬预算 max-rounds(当前 ${current})· 不是无限`
|
|
350
|
+
: `until-clean hard budget max-rounds (current ${current}) · not unlimited`,
|
|
351
|
+
[
|
|
352
|
+
zh ? `5 · 最多 5 个 host 周期` : `5 · up to 5 host cycles`,
|
|
353
|
+
zh ? `10 · 默认` : `10 · default`,
|
|
354
|
+
zh ? `15 · 更长` : `15 · longer`,
|
|
355
|
+
zh ? `20 · 上限偏松` : `20 · looser cap`,
|
|
356
|
+
].sort((a, b) => (Number(a.split(" · ")[0]) === current ? -1 : Number(b.split(" · ")[0]) === current ? 1 : 0)),
|
|
357
|
+
);
|
|
358
|
+
if (budget === CANCEL) return undefined;
|
|
359
|
+
maxRounds = Number(budget.split(" · ")[0]) || RV_LOOP_UNTIL_CLEAN_DEFAULT_MAX_ROUNDS;
|
|
360
|
+
} else {
|
|
361
|
+
until = undefined;
|
|
362
|
+
const current = maxRounds ?? RV_LOOP_DEFAULT_MAX_ROUNDS;
|
|
363
|
+
const options = [
|
|
364
|
+
zh ? `1 · 修一次再审(推荐)` : `1 · host fix point (recommended)`,
|
|
365
|
+
zh ? `2 · 两轮` : `2 · two gates`,
|
|
366
|
+
zh ? `3 · 三轮` : `3 · three gates`,
|
|
367
|
+
];
|
|
368
|
+
const ordered = [...options].sort((a, b) => {
|
|
369
|
+
const av = Number(a[0]) === current ? 0 : 1;
|
|
370
|
+
const bv = Number(b[0]) === current ? 0 : 1;
|
|
371
|
+
return av - bv;
|
|
372
|
+
});
|
|
373
|
+
const rounds = await mustSelect(
|
|
374
|
+
ui,
|
|
375
|
+
zh
|
|
376
|
+
? `审查轮数 max-rounds(当前 ${current})· 与 reviewers 人数无关`
|
|
377
|
+
: `Review rounds max-rounds (current ${current}) · not reviewer count`,
|
|
378
|
+
ordered,
|
|
379
|
+
);
|
|
380
|
+
if (rounds === CANCEL) return undefined;
|
|
381
|
+
maxRounds = Number(rounds[0]) || RV_LOOP_DEFAULT_MAX_ROUNDS;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 4) Panel shape: preset vs N reviewers
|
|
386
|
+
let panel = seed.panel;
|
|
387
|
+
let reviewers = seed.reviewers;
|
|
388
|
+
const reviewerModels = [...(seed.reviewerModels ?? [])];
|
|
389
|
+
|
|
390
|
+
if (!panel && reviewers === undefined) {
|
|
391
|
+
const shape = await mustSelect(
|
|
392
|
+
ui,
|
|
393
|
+
zh ? "Panel 形态" : "Panel shape",
|
|
394
|
+
[
|
|
395
|
+
zh ? "预设 code-experts(正确性/安全/测试)" : "Preset code-experts (correctness/security/testing)",
|
|
396
|
+
zh ? "自定义人数 2–8(r1..rN,可逐个选模型)" : "Custom reviewer count 2–8 (r1..rN, pick models)",
|
|
397
|
+
// Single-reviewer is a different product path (no panel). Keep it explicit and rare.
|
|
398
|
+
zh ? "单 reviewer 非 panel(无共识,仅 shell 单审查)" : "Single reviewer non-panel (no consensus; shell single review)",
|
|
399
|
+
],
|
|
400
|
+
);
|
|
401
|
+
if (shape === CANCEL) return undefined;
|
|
402
|
+
if (shape.includes("code-experts") || shape.includes("Preset")) {
|
|
403
|
+
panel = "code-experts";
|
|
404
|
+
} else if (shape.includes("non-panel") || shape.includes("非 panel") || shape.includes("Single reviewer non-panel")) {
|
|
405
|
+
// Explicit non-panel single review. Parent must use shell CLI, not pi_review panel tool.
|
|
406
|
+
reviewers = 1;
|
|
407
|
+
panel = undefined;
|
|
408
|
+
} else {
|
|
409
|
+
const countChoice = await mustSelect(
|
|
410
|
+
ui,
|
|
411
|
+
zh ? "独立 reviewer 人数(宽度,≥2 才是 panel)" : "Independent reviewers (width, panel requires ≥2)",
|
|
412
|
+
["2", "3", "4", "5"],
|
|
413
|
+
);
|
|
414
|
+
if (countChoice === CANCEL) return undefined;
|
|
415
|
+
reviewers = Number(countChoice);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Keep reviewers=1 explicit: orchestration uses it to select the non-panel shell path.
|
|
420
|
+
// Collapsing it to undefined would be indistinguishable from the default code-experts panel.
|
|
421
|
+
|
|
422
|
+
// 5) Per-reviewer models via select dialogs (no tab completion)
|
|
423
|
+
const ids =
|
|
424
|
+
panel === "code-experts"
|
|
425
|
+
? ["correctness", "security", "testing"]
|
|
426
|
+
: reviewers && reviewers > 1
|
|
427
|
+
? Array.from({ length: reviewers }, (_, i) => `r${i + 1}`)
|
|
428
|
+
: [];
|
|
429
|
+
|
|
430
|
+
const labels = rankedModelLabels(models, mode);
|
|
431
|
+
|
|
432
|
+
if (ids.length > 0) {
|
|
433
|
+
const assignEach = await ui.confirm(
|
|
434
|
+
zh ? "为每位 reviewer 选择模型?" : "Pick a model for each reviewer?",
|
|
435
|
+
zh
|
|
436
|
+
? `将依次为 ${ids.join(", ")} 弹出模型列表(方向键选择,回车确认)。选「同一模型」可共用。`
|
|
437
|
+
: `You will pick models for ${ids.join(", ")} from a list (arrows + enter). Choose "Same model for all" to share.`,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
if (assignEach) {
|
|
441
|
+
const sameFirst = await mustSelect(
|
|
442
|
+
ui,
|
|
443
|
+
zh ? "模型分配方式" : "Model assignment",
|
|
444
|
+
[
|
|
445
|
+
zh ? "每位 reviewer 分别选择" : "Pick separately for each reviewer",
|
|
446
|
+
zh ? "所有 reviewer 使用同一模型" : "Same model for all reviewers",
|
|
447
|
+
],
|
|
448
|
+
);
|
|
449
|
+
if (sameFirst === CANCEL) return undefined;
|
|
450
|
+
|
|
451
|
+
if (sameFirst.includes("Same") || sameFirst.includes("同一")) {
|
|
452
|
+
if (labels.length === 0) {
|
|
453
|
+
ui.notify(zh ? "当前没有可用模型目录,请先 /rv-models" : "No model catalog available; run /rv-models first", "warning");
|
|
454
|
+
} else {
|
|
455
|
+
const pick = await pickModelLabel(ui, models, mode, locale, zh ? "共用模型" : "Shared model", false);
|
|
456
|
+
if (pick === CANCEL) return undefined;
|
|
457
|
+
if (pick !== undefined) {
|
|
458
|
+
const m = modelByLabel(models, pick);
|
|
459
|
+
const thinkingPick = await mustSelect(
|
|
460
|
+
ui,
|
|
461
|
+
zh ? `思考强度(${pick})` : `Thinking (${pick})`,
|
|
462
|
+
thinkingOptions(m, locale),
|
|
463
|
+
);
|
|
464
|
+
if (thinkingPick === CANCEL) return undefined;
|
|
465
|
+
const thinking = stripSkip(thinkingPick, locale);
|
|
466
|
+
const token = thinking ? `${pick}:${thinking}` : pick;
|
|
467
|
+
for (const id of ids) reviewerModels.push(`${id}=${token}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
for (const id of ids) {
|
|
472
|
+
if (labels.length === 0) break;
|
|
473
|
+
const pick = await pickModelLabel(ui, models, mode, locale, zh ? `模型 · ${id}` : `Model · ${id}`, false);
|
|
474
|
+
if (pick === CANCEL) return undefined;
|
|
475
|
+
if (pick === undefined) continue; // defensive: allowSkip=false never returns undefined here
|
|
476
|
+
const m = modelByLabel(models, pick);
|
|
477
|
+
const thinkingPick = await mustSelect(
|
|
478
|
+
ui,
|
|
479
|
+
zh ? `思考强度 · ${id}` : `Thinking · ${id}`,
|
|
480
|
+
thinkingOptions(m, locale),
|
|
481
|
+
);
|
|
482
|
+
if (thinkingPick === CANCEL) return undefined;
|
|
483
|
+
const thinking = stripSkip(thinkingPick, locale);
|
|
484
|
+
reviewerModels.push(`${id}=${thinking ? `${pick}:${thinking}` : pick}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} else if (!seed.model && labels.length > 0) {
|
|
488
|
+
// Shared default model when not assigning per-reviewer
|
|
489
|
+
const pick = await pickModelLabel(ui, models, mode, locale, zh ? "默认模型(全体 reviewer)" : "Default model (all reviewers)", true);
|
|
490
|
+
if (pick === CANCEL) return undefined;
|
|
491
|
+
if (pick !== undefined) {
|
|
492
|
+
const m = modelByLabel(models, pick);
|
|
493
|
+
const thinkingPick = await mustSelect(
|
|
494
|
+
ui,
|
|
495
|
+
zh ? "默认思考强度" : "Default thinking",
|
|
496
|
+
thinkingOptions(m, locale),
|
|
497
|
+
);
|
|
498
|
+
if (thinkingPick === CANCEL) return undefined;
|
|
499
|
+
const thinking = stripSkip(thinkingPick, locale);
|
|
500
|
+
seed.model = pick;
|
|
501
|
+
if (thinking) seed.thinking = thinking;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
} else if (!seed.model && labels.length > 0) {
|
|
505
|
+
// Single-reviewer path
|
|
506
|
+
const pick = await pickModelLabel(ui, models, mode, locale, zh ? "模型" : "Model", true);
|
|
507
|
+
if (pick === CANCEL) return undefined;
|
|
508
|
+
if (pick !== undefined) {
|
|
509
|
+
seed.model = pick;
|
|
510
|
+
const m = modelByLabel(models, pick);
|
|
511
|
+
const thinkingPick = await mustSelect(ui, zh ? "思考强度" : "Thinking", thinkingOptions(m, locale));
|
|
512
|
+
if (thinkingPick === CANCEL) return undefined;
|
|
513
|
+
const thinking = stripSkip(thinkingPick, locale);
|
|
514
|
+
if (thinking) seed.thinking = thinking;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 6) Consensus when multi-reviewer
|
|
519
|
+
let consensus = seed.consensus;
|
|
520
|
+
let minAgree = seed.minAgree;
|
|
521
|
+
if ((panel || (reviewers ?? 0) > 1) && !consensus) {
|
|
522
|
+
const c = await mustSelect(
|
|
523
|
+
ui,
|
|
524
|
+
zh ? "共识策略" : "Consensus policy",
|
|
525
|
+
RV_CONSENSUS_POLICIES.map((p) => {
|
|
526
|
+
if (p === "quorum") return zh ? "quorum · 至少 min-agree 人同意(默认)" : "quorum · at least min-agree agree (default)";
|
|
527
|
+
if (p === "majority") return zh ? "majority · 过半数" : "majority · floor(n/2)+1";
|
|
528
|
+
if (p === "unanimous") return zh ? "unanimous · 全员同意" : "unanimous · all must agree";
|
|
529
|
+
return zh ? "any · 任一即可" : "any · single actionable reviewer";
|
|
530
|
+
}),
|
|
531
|
+
);
|
|
532
|
+
if (c === CANCEL) return undefined;
|
|
533
|
+
consensus = c.split(" · ")[0] as RvConsensusPolicy;
|
|
534
|
+
if (consensus === "quorum" && minAgree === undefined) {
|
|
535
|
+
const m = await mustSelect(
|
|
536
|
+
ui,
|
|
537
|
+
zh ? "quorum 最少同意人数" : "Quorum min-agree",
|
|
538
|
+
["2", "3", "4"],
|
|
539
|
+
);
|
|
540
|
+
if (m === CANCEL) return undefined;
|
|
541
|
+
minAgree = Number(m);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 7) Confirm summary
|
|
546
|
+
const summaryLines = [
|
|
547
|
+
`${zh ? "策略" : "Strategy"}: ${strategy}`,
|
|
548
|
+
`${zh ? "目标" : "Target"}: ${target}`,
|
|
549
|
+
`${zh ? "模式" : "Mode"}: ${mode}`,
|
|
550
|
+
strategy === "loop"
|
|
551
|
+
? until === "clean"
|
|
552
|
+
? `${zh ? "目标" : "Goal"}: until clean · budget ${maxRounds ?? RV_LOOP_UNTIL_CLEAN_DEFAULT_MAX_ROUNDS}\n${zh ? "Clean 定义" : "Clean means"}: ${RV_CLEAN_GOAL.summary}`
|
|
553
|
+
: `${zh ? "审查轮数 max-rounds" : "Review rounds max-rounds"}: ${maxRounds ?? RV_LOOP_DEFAULT_MAX_ROUNDS}`
|
|
554
|
+
: `${zh ? "审查轮数" : "Review rounds"}: 1 (${zh ? "/rv 单次 panel,无 loop" : "/rv is a single panel gate"})`,
|
|
555
|
+
panel
|
|
556
|
+
? `panel: ${panel}`
|
|
557
|
+
: reviewers
|
|
558
|
+
? `${zh ? "每轮 reviewers" : "reviewers per round"}: ${reviewers}`
|
|
559
|
+
: zh
|
|
560
|
+
? "单 reviewer"
|
|
561
|
+
: "single reviewer",
|
|
562
|
+
seed.model ? `model: ${seed.model}${seed.thinking ? `:${seed.thinking}` : ""}` : "",
|
|
563
|
+
reviewerModels.length ? `reviewer-models: ${reviewerModels.join(", ")}` : "",
|
|
564
|
+
consensus ? `consensus: ${consensus}${minAgree ? ` min-agree=${minAgree}` : ""}` : "",
|
|
565
|
+
].filter(Boolean);
|
|
566
|
+
|
|
567
|
+
const ok = await ui.confirm(
|
|
568
|
+
zh ? "确认开始审查?" : "Start review?",
|
|
569
|
+
summaryLines.join("\n"),
|
|
570
|
+
);
|
|
571
|
+
if (!ok) {
|
|
572
|
+
ui.notify(zh ? "已取消" : "Cancelled", "info");
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
...seed,
|
|
578
|
+
strategy,
|
|
579
|
+
mode,
|
|
580
|
+
target,
|
|
581
|
+
...(maxRounds !== undefined ? { maxRounds } : {}),
|
|
582
|
+
...(until ? { until } : {}),
|
|
583
|
+
...(panel ? { panel } : {}),
|
|
584
|
+
...(reviewers !== undefined ? { reviewers } : {}),
|
|
585
|
+
...(reviewerModels.length ? { reviewerModels } : {}),
|
|
586
|
+
...(seed.model ? { model: seed.model } : {}),
|
|
587
|
+
...(seed.thinking ? { thinking: seed.thinking } : {}),
|
|
588
|
+
...(consensus ? { consensus } : {}),
|
|
589
|
+
...(minAgree !== undefined ? { minAgree } : {}),
|
|
590
|
+
keepSession: seed.keepSession,
|
|
591
|
+
noStream: seed.noStream,
|
|
592
|
+
modelsOnly: false,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const INTERACTIVE_TOKEN_RE = /(?:^|\s)(?:interactive|--interactive|-i)(?=\s|$)/i;
|
|
597
|
+
|
|
598
|
+
/** True when the invocation should open the interactive wizard. */
|
|
599
|
+
export function shouldRunInteractiveWizard(rawArgs: string, parsed: RvParsed): boolean {
|
|
600
|
+
const trimmed = rawArgs.trim();
|
|
601
|
+
if (!trimmed) return true;
|
|
602
|
+
if (INTERACTIVE_TOKEN_RE.test(` ${trimmed} `) || /^(interactive|--interactive|-i)\b/i.test(trimmed)) {
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
// Power users with full flags are never interrupted unless they ask for interactive.
|
|
606
|
+
void parsed;
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Remove interactive trigger tokens anywhere in the arg string. */
|
|
611
|
+
export function stripInteractiveToken(rawArgs: string): string {
|
|
612
|
+
return rawArgs
|
|
613
|
+
.replace(/(?:^|\s)(?:interactive|--interactive|-i)(?=\s|$)/gi, " ")
|
|
614
|
+
.replace(/\s+/g, " ")
|
|
615
|
+
.trim();
|
|
616
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "
|
|
2
|
+
import { describe, it } from "vitest";
|
|
3
3
|
import { detectRvLocale } from "./rv-locale.js";
|
|
4
4
|
|
|
5
5
|
describe("detectRvLocale", () => {
|
|
@@ -8,10 +8,18 @@ describe("detectRvLocale", () => {
|
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
it("returns en for English-only samples", () => {
|
|
11
|
-
assert.equal(detectRvLocale(["Please review this diff", "Thanks"]), "en");
|
|
11
|
+
assert.equal(detectRvLocale(["Please review this diff", "Thanks"], { LANG: "en_US.UTF-8" }), "en");
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
it("defaults to
|
|
15
|
-
assert.equal(detectRvLocale([]), "
|
|
14
|
+
it("defaults to system zh when samples empty", () => {
|
|
15
|
+
assert.equal(detectRvLocale([], { LANG: "zh_CN.UTF-8" }), "zh");
|
|
16
|
+
assert.equal(detectRvLocale([], { LANG: "en_US.UTF-8" }), "en");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("prefers zh for mixed samples with any meaningful Chinese", () => {
|
|
20
|
+
assert.equal(
|
|
21
|
+
detectRvLocale(["Please review this diff", "帮我看下鉴权"]),
|
|
22
|
+
"zh",
|
|
23
|
+
);
|
|
16
24
|
});
|
|
17
25
|
});
|