pi-distill 1.9.2 → 1.10.0

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.
@@ -0,0 +1,616 @@
1
+ /**
2
+ * pi-distill 的 TUI 配置面板。
3
+ *
4
+ * 原来用 `ctx.ui.select` 摆一串「字段:值」文本,屏幕上分不清哪行是状态、哪行按下去会怎样。
5
+ * 这里改用 Pi 自带 `SettingsList`:左边字段名、右边当前值、选中项下方一行说明;开关原地切换,
6
+ * 枚举与模型回车打开二级列表,二级列表上方带过滤输入框。改一项立即写盘并对本次会话生效,
7
+ * Esc 关闭即可。
8
+ *
9
+ * 工具开关是运行期才知道名字的动态字段:可用工具列表有几个工具,面板就多几行 `tools.<工具名>`。
10
+ */
11
+
12
+ import {
13
+ getSelectListTheme,
14
+ getSettingsListTheme,
15
+ type ExtensionCommandContext,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import {
18
+ Container,
19
+ type Component,
20
+ Input,
21
+ SelectList,
22
+ SettingsList,
23
+ type SelectItem,
24
+ type SettingItem,
25
+ Text,
26
+ fuzzyFilter,
27
+ getKeybindings,
28
+ } from "@earendil-works/pi-tui";
29
+ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
30
+
31
+ const i18n = createTranslator(loadCatalog(new URL("../locales/index.json", import.meta.url)));
32
+
33
+ /** 面板上固定出现的字段名;工具开关按运行期工具名动态追加。 */
34
+ export const PANEL_FIELD = {
35
+ enabled: "enabled",
36
+ model: "model",
37
+ minChars: "minChars",
38
+ maxChars: "maxChars",
39
+ maxOutputChars: "maxOutputChars",
40
+ timeoutSeconds: "timeoutSeconds",
41
+ timeoutRetryCount: "timeoutRetryCount",
42
+ errorRetryCount: "errorRetryCount",
43
+ missedCompressionRatio: "missedCompressionRatio",
44
+ summarizeErrors: "summarizeErrors",
45
+ renderEnabled: "render.enabled",
46
+ renderShowPrompt: "render.showPrompt",
47
+ renderShowResult: "render.showResult",
48
+ } as const;
49
+
50
+ /** 固定字段名的联合类型。 */
51
+ export type PanelFieldId = (typeof PANEL_FIELD)[keyof typeof PANEL_FIELD];
52
+
53
+ /** 工具开关字段名的前缀;完整字段名是 `tools.<工具名>`。 */
54
+ export const TOOL_FIELD_PREFIX = "tools.";
55
+
56
+ /** 工具开关的字段名。 */
57
+ export function toolFieldId(toolName: string): string {
58
+ return `${TOOL_FIELD_PREFIX}${toolName}`;
59
+ }
60
+
61
+ /** 从工具开关字段名里取回工具名;不是工具字段时返回 undefined。 */
62
+ export function toolNameFromFieldId(id: string): string | undefined {
63
+ return id.startsWith(TOOL_FIELD_PREFIX) ? id.slice(TOOL_FIELD_PREFIX.length) : undefined;
64
+ }
65
+
66
+ /** 面板读取的配置快照:与配置文件结构一致,缺省字段按默认值补齐。 */
67
+ export interface DistillPanelConfig {
68
+ /** 总开关。 */
69
+ enabled: boolean;
70
+ /** `provider/model`;空串表示用当前会话模型。 */
71
+ model: string;
72
+ /** 触发提炼的最小输出字符数。 */
73
+ minChars: number;
74
+ /** 提炼结果超过此字符数时写文件。 */
75
+ maxChars: number;
76
+ /** 最终返回内容超过此字符数时写文件。 */
77
+ maxOutputChars: number;
78
+ /** 提炼模型调用超时秒数。 */
79
+ timeoutSeconds: number;
80
+ /** 超时后的额外重试次数。 */
81
+ timeoutRetryCount: number;
82
+ /** 非超时异常后的额外重试次数。 */
83
+ errorRetryCount: number;
84
+ /** 没有 outputRequest 的长输出触发提醒的倍数。 */
85
+ missedCompressionRatio: number;
86
+ /** 工具报错时是否仍提炼。 */
87
+ summarizeErrors: boolean;
88
+ /** 是否启用审计渲染器。 */
89
+ renderEnabled: boolean;
90
+ /** 审计卡是否显示 outputRequest。 */
91
+ renderShowPrompt: boolean;
92
+ /** 审计卡是否显示提炼结果。 */
93
+ renderShowResult: boolean;
94
+ /** 每个工具的开关覆盖;写回时只保留与默认值不同的项。 */
95
+ tools: Record<string, boolean>;
96
+ }
97
+
98
+ /** 面板可选的模型:Pi 运行期注册的模型。 */
99
+ export interface ModelRef {
100
+ /** provider id,例如 `llm-proxy`。 */
101
+ provider: string;
102
+ /** provider 内的模型 id,例如 `LOW`。 */
103
+ id: string;
104
+ }
105
+
106
+ /** 字段的取值方式。 */
107
+ const PANEL_KIND = {
108
+ /** 开关:Enter/空格原地切换。 */
109
+ toggle: "toggle",
110
+ /** 枚举/数值:Enter 打开二级列表。 */
111
+ choice: "choice",
112
+ /** 模型:Enter 打开可搜索的二级列表。 */
113
+ model: "model",
114
+ } as const;
115
+
116
+ /** 字段的取值方式联合类型。 */
117
+ type PanelKind = (typeof PANEL_KIND)[keyof typeof PANEL_KIND];
118
+
119
+ /** 一个固定面板项:叫什么、怎么编辑。 */
120
+ interface PanelFieldSpec {
121
+ /** 字段名。 */
122
+ id: PanelFieldId;
123
+ /** 取值方式。 */
124
+ kind: PanelKind;
125
+ /** 展示标题的文案 key。 */
126
+ labelKey: string;
127
+ /** 说明行的文案 key。 */
128
+ descriptionKey: string;
129
+ }
130
+
131
+ /** 面板顺序:总开关、模型、阈值,最后是渲染开关。 */
132
+ const PANEL_FIELDS: readonly PanelFieldSpec[] = [
133
+ { id: PANEL_FIELD.enabled, kind: PANEL_KIND.toggle, labelKey: "panelLabelEnabled", descriptionKey: "panelDescEnabled" },
134
+ { id: PANEL_FIELD.model, kind: PANEL_KIND.model, labelKey: "panelLabelModel", descriptionKey: "panelDescModel" },
135
+ { id: PANEL_FIELD.minChars, kind: PANEL_KIND.choice, labelKey: "panelLabelMinChars", descriptionKey: "panelDescMinChars" },
136
+ { id: PANEL_FIELD.maxChars, kind: PANEL_KIND.choice, labelKey: "panelLabelMaxChars", descriptionKey: "panelDescMaxChars" },
137
+ { id: PANEL_FIELD.maxOutputChars, kind: PANEL_KIND.choice, labelKey: "panelLabelMaxOutputChars", descriptionKey: "panelDescMaxOutputChars" },
138
+ { id: PANEL_FIELD.timeoutSeconds, kind: PANEL_KIND.choice, labelKey: "panelLabelTimeoutSeconds", descriptionKey: "panelDescTimeoutSeconds" },
139
+ { id: PANEL_FIELD.timeoutRetryCount, kind: PANEL_KIND.choice, labelKey: "panelLabelTimeoutRetryCount", descriptionKey: "panelDescTimeoutRetryCount" },
140
+ { id: PANEL_FIELD.errorRetryCount, kind: PANEL_KIND.choice, labelKey: "panelLabelErrorRetryCount", descriptionKey: "panelDescErrorRetryCount" },
141
+ { id: PANEL_FIELD.missedCompressionRatio, kind: PANEL_KIND.choice, labelKey: "panelLabelMissedCompressionRatio", descriptionKey: "panelDescMissedCompressionRatio" },
142
+ { id: PANEL_FIELD.summarizeErrors, kind: PANEL_KIND.toggle, labelKey: "panelLabelSummarizeErrors", descriptionKey: "panelDescSummarizeErrors" },
143
+ { id: PANEL_FIELD.renderEnabled, kind: PANEL_KIND.toggle, labelKey: "panelLabelRenderEnabled", descriptionKey: "panelDescRenderEnabled" },
144
+ { id: PANEL_FIELD.renderShowPrompt, kind: PANEL_KIND.toggle, labelKey: "panelLabelRenderShowPrompt", descriptionKey: "panelDescRenderShowPrompt" },
145
+ { id: PANEL_FIELD.renderShowResult, kind: PANEL_KIND.toggle, labelKey: "panelLabelRenderShowResult", descriptionKey: "panelDescRenderShowResult" },
146
+ ];
147
+
148
+ /** 固定字段名清单,测试用它核对没有配置字段漏在面板外。 */
149
+ export const PANEL_FIELD_IDS: readonly string[] = PANEL_FIELDS.map((field) => field.id);
150
+
151
+ /** 数值字段的候选值。 */
152
+ const NUMBER_FIELD_VALUES: Readonly<Record<string, readonly string[]>> = {
153
+ [PANEL_FIELD.minChars]: ["50", "100", "200", "500", "1000", "5000"],
154
+ [PANEL_FIELD.maxChars]: ["10000", "50000", "100000", "500000"],
155
+ [PANEL_FIELD.maxOutputChars]: ["2000", "5000", "10000", "50000"],
156
+ [PANEL_FIELD.timeoutSeconds]: ["5", "10", "30", "60", "120"],
157
+ [PANEL_FIELD.timeoutRetryCount]: ["0", "1", "2", "3"],
158
+ [PANEL_FIELD.errorRetryCount]: ["0", "1", "2", "3"],
159
+ [PANEL_FIELD.missedCompressionRatio]: ["2", "5", "10", "20", "50"],
160
+ };
161
+
162
+ /** 二级列表最多同时显示几行;超出的部分由 SelectList 滚动。 */
163
+ const CHOICE_MENU_MAX_VISIBLE = 10;
164
+
165
+ /** 开关在面板上的两种显示文案。 */
166
+ export interface ToggleLabels {
167
+ /** 打开态文案。 */
168
+ on: string;
169
+ /** 关闭态文案。 */
170
+ off: string;
171
+ }
172
+
173
+ /** 取本地化后的开关文案。 */
174
+ export function panelToggleLabels(): ToggleLabels {
175
+ return { on: i18n.t("on"), off: i18n.t("off") };
176
+ }
177
+
178
+ /** 模型字段里「当前会话模型」那一项的展示文案。 */
179
+ export function panelCurrentModelLabel(): string {
180
+ return i18n.t("currentModel");
181
+ }
182
+
183
+ /** 二级列表的一项:label 是展示文本,value 是写回配置的值。 */
184
+ export interface PanelOption {
185
+ /** 展示文本。 */
186
+ label: string;
187
+ /** 写回配置的值。 */
188
+ value: string;
189
+ }
190
+
191
+ /** 开关字段的候选项。 */
192
+ function toggleOptions(): PanelOption[] {
193
+ const labels = panelToggleLabels();
194
+ return [
195
+ { label: labels.on, value: "true" },
196
+ { label: labels.off, value: "false" },
197
+ ];
198
+ }
199
+
200
+ /** 数值字段的候选项。 */
201
+ function numberOptions(id: string): PanelOption[] {
202
+ const values = NUMBER_FIELD_VALUES[id] ?? [];
203
+ return values.map((value) => ({ label: value, value }));
204
+ }
205
+
206
+ /** 模型字段的候选项:空串是「当前会话模型」,其余是 `provider/modelId`。 */
207
+ function modelOptions(models: readonly ModelRef[]): PanelOption[] {
208
+ const options: PanelOption[] = [{ label: panelCurrentModelLabel(), value: "" }];
209
+ const seen = new Set<string>([""]);
210
+ for (const model of models) {
211
+ const reference = `${model.provider}/${model.id}`;
212
+ if (seen.has(reference)) continue;
213
+ seen.add(reference);
214
+ options.push({ label: reference, value: reference });
215
+ }
216
+ return options;
217
+ }
218
+
219
+ /** 按存储值取展示文本;不在候选表里就直接显示原值。 */
220
+ export function optionLabelForValue(options: readonly PanelOption[], value: string): string {
221
+ return options.find((option) => option.value === value)?.label ?? value;
222
+ }
223
+
224
+ /** 按展示文本反查存储值;找不到返回 undefined。 */
225
+ export function optionValueFromLabel(
226
+ options: readonly PanelOption[],
227
+ label: string,
228
+ ): string | undefined {
229
+ return options.find((option) => option.label === label)?.value;
230
+ }
231
+
232
+ /** 开关值对应展示文本。 */
233
+ function toggleLabel(value: boolean, labels: ToggleLabels): string {
234
+ return value ? labels.on : labels.off;
235
+ }
236
+
237
+ /** 按展示文本换算开关值;未知文本返回 undefined。 */
238
+ function toggleValueFromLabel(value: string, labels: ToggleLabels): boolean | undefined {
239
+ if (value === labels.on) return true;
240
+ if (value === labels.off) return false;
241
+ return undefined;
242
+ }
243
+
244
+ /**
245
+ * 按关键词过滤候选项;空关键词原样返回。
246
+ *
247
+ * `SelectList` 自带的 setFilter 只做前缀匹配,输入 `low` 永远找不到 `llm-proxy/LOW`。
248
+ * 模糊匹配会给词边界加分,所以 `provider/model` 这种名字从哪一段搜都行。
249
+ */
250
+ export function filterOptions(options: readonly PanelOption[], query: string): PanelOption[] {
251
+ return query.trim() ? fuzzyFilter([...options], query, (option) => option.label) : [...options];
252
+ }
253
+
254
+ /** 面板需要手动输入的字段(数值与模型)允许把输入框里的原样文本当成候选项。 */
255
+ function isFreetextField(id: string): boolean {
256
+ return id === PANEL_FIELD.model || id in NUMBER_FIELD_VALUES;
257
+ }
258
+
259
+ /** 取小数合法的字段:只有长输出阈值允许小数,其余都是整数。 */
260
+ function isDecimalField(id: string): boolean {
261
+ return id === PANEL_FIELD.missedCompressionRatio;
262
+ }
263
+
264
+ /** 校验手动输入:数值字段必须是正数(阈值可为小数),模型字段必须是 `provider/model`;空串合法。 */
265
+ export function isCustomValueValid(id: string, value: string): boolean {
266
+ const trimmed = value.trim();
267
+ if (id === PANEL_FIELD.model) return trimmed === "" || /^[^/\s]+\/[^/\s]+$/.test(trimmed);
268
+ if (id in NUMBER_FIELD_VALUES) {
269
+ if (trimmed === "") return true;
270
+ return isDecimalField(id) ? /^\d+(?:\.\d+)?$/.test(trimmed) : /^\d+$/.test(trimmed);
271
+ }
272
+ return false;
273
+ }
274
+
275
+ /** 二级列表的构造参数。 */
276
+ interface ChoiceSubmenuOptions {
277
+ /** 字段名,决定手动输入内容按哪种规则校验。 */
278
+ id: string;
279
+ /** 候选项。 */
280
+ options: readonly PanelOption[];
281
+ /** 当前值,用来预选中对应候选。 */
282
+ currentValue: string;
283
+ /** 选定回调;传 undefined 表示用户按 Esc 放弃。 */
284
+ done: (selectedLabel?: string) => void;
285
+ /** 是否在列表上方加一个过滤输入框。 */
286
+ search: boolean;
287
+ }
288
+
289
+ /**
290
+ * 二级选择列表:Enter 选定并回传展示文本,Esc 不改动直接返回。
291
+ *
292
+ * `search` 打开时,列表上方多一个输入框:方向键仍然给列表,其余按键(含 Enter)给输入框,
293
+ * 每次输入都按 `filterOptions` 重建列表。候选表很长时这是唯一顺手的做法。
294
+ *
295
+ * 允许手动输入的字段(数值、模型)在过滤框有内容时,列表顶部固定一项「使用 “xxx”」,
296
+ * 于是「从候选里挑」和「自己填一个」在同一个列表里完成,不用再开一层菜单。
297
+ */
298
+ function createChoiceSubmenu(params: ChoiceSubmenuOptions): Component {
299
+ const { id, options, currentValue, done, search } = params;
300
+ /** 输入框里的内容能不能直接当值用。 */
301
+ const customOption = (query: string): PanelOption | undefined => {
302
+ const trimmed = query.trim();
303
+ if (!isFreetextField(id) || trimmed === "" || !isCustomValueValid(id, trimmed)) {
304
+ return undefined;
305
+ }
306
+ if (options.some((option) => option.value === trimmed)) return undefined;
307
+ return { label: i18n.t("panelUseCustom", { value: trimmed }), value: trimmed };
308
+ };
309
+
310
+ /** 建一份列表快照,能对上当前值就预选中它。 */
311
+ const buildList = (list: readonly PanelOption[], preselect: string | undefined): SelectList => {
312
+ // 列表项的 value 与 label 取同一段文本:SettingsList 会把回传值直接显示在右侧,
313
+ // 回传展示文本才能让「当前会话模型」这类文案在选中后仍然可读。
314
+ const items: SelectItem[] = list.map((option) => ({ value: option.label, label: option.label }));
315
+ const selectList = new SelectList(
316
+ items,
317
+ Math.max(1, Math.min(items.length, CHOICE_MENU_MAX_VISIBLE)),
318
+ getSelectListTheme(),
319
+ );
320
+ const index = list.findIndex((option) => option.value === preselect);
321
+ if (index >= 0) selectList.setSelectedIndex(index);
322
+ selectList.onSelect = (item) => done(item.value);
323
+ selectList.onCancel = () => done(undefined);
324
+ return selectList;
325
+ };
326
+
327
+ /** 过滤后的列表:手动输入项排在候选前面。 */
328
+ const composeList = (query: string, preselect: string | undefined): readonly PanelOption[] => {
329
+ const custom = customOption(query);
330
+ const filtered = filterOptions(options, query);
331
+ return custom ? [custom, ...filtered] : filtered;
332
+ };
333
+
334
+ if (!search) return buildList(options, currentValue);
335
+
336
+ const input = new Input({ prompt: `${i18n.t("panelSearchPrompt")} ` });
337
+ input.focused = true;
338
+ input.onEscape = () => done(undefined);
339
+
340
+ let list = buildList(options, currentValue);
341
+ let query = "";
342
+ input.onSubmit = () => {
343
+ const selected = list.getSelectedItem();
344
+ if (selected) done(selected.value);
345
+ };
346
+
347
+ // SettingsList 的 submenu 要求返回 Component,这里只把三个方法转发给两个子组件。
348
+ return {
349
+ /** 上面过滤输入框,下面过滤后的候选。 */
350
+ render: (width) => [...input.render(width), ...list.render(width)],
351
+ /** 两个子组件都没有共享缓存。 */
352
+ invalidate: () => {
353
+ input.invalidate();
354
+ list.invalidate();
355
+ },
356
+ /** 方向键给列表,其余按键(含 Enter)给输入框。 */
357
+ handleInput: (data) => {
358
+ const keybindings = getKeybindings();
359
+ if (
360
+ keybindings.matches(data, "tui.select.up") ||
361
+ keybindings.matches(data, "tui.select.down")
362
+ ) {
363
+ list.handleInput(data);
364
+ return;
365
+ }
366
+ input.handleInput(data);
367
+ const next = input.getValue();
368
+ if (next !== query) {
369
+ query = next;
370
+ list = buildList(composeList(next, customOption(next)?.value), undefined);
371
+ }
372
+ },
373
+ };
374
+ }
375
+
376
+ /** 把配置快照转成 Pi 设置列表的条目;工具开关按传入的工具名逐行生成。 */
377
+ export function buildSettingItems(
378
+ config: DistillPanelConfig,
379
+ models: readonly ModelRef[],
380
+ toolNames: readonly string[] = [],
381
+ ): SettingItem[] {
382
+ const labels = panelToggleLabels();
383
+ const modelChoices = modelOptions(models);
384
+ const items: SettingItem[] = PANEL_FIELDS.map((field) => {
385
+ const label = i18n.t(field.labelKey);
386
+ const description = i18n.t(field.descriptionKey);
387
+ switch (field.kind) {
388
+ case PANEL_KIND.toggle:
389
+ return {
390
+ id: field.id,
391
+ label,
392
+ description,
393
+ currentValue: toggleLabel(readToggle(config, field.id), labels),
394
+ values: toggleOptions().map((option) => option.label),
395
+ };
396
+ case PANEL_KIND.model:
397
+ return {
398
+ id: field.id,
399
+ label,
400
+ description,
401
+ currentValue: optionLabelForValue(modelChoices, config.model),
402
+ // 二级列表接管 Enter,模型动辄上百条,所以这一项要过滤输入框。
403
+ submenu: (currentValue: string, done) =>
404
+ createChoiceSubmenu({ id: field.id, options: modelChoices, currentValue, done, search: true }),
405
+ };
406
+ case PANEL_KIND.choice:
407
+ return {
408
+ id: field.id,
409
+ label,
410
+ description,
411
+ currentValue: optionLabelForValue(numberOptions(field.id), readNumber(config, field.id)),
412
+ submenu: (currentValue: string, done) =>
413
+ createChoiceSubmenu({
414
+ id: field.id,
415
+ options: numberOptions(field.id),
416
+ currentValue,
417
+ done,
418
+ search: true,
419
+ }),
420
+ };
421
+ }
422
+ });
423
+
424
+ for (const toolName of toolNames) {
425
+ items.push({
426
+ id: toolFieldId(toolName),
427
+ label: i18n.t("panelLabelTool", { tool: toolName }),
428
+ description: i18n.t("panelDescTool", { tool: toolName }),
429
+ currentValue: toggleLabel(config.tools[toolName] ?? defaultToolEnabled(toolName), labels),
430
+ values: toggleOptions().map((option) => option.label),
431
+ });
432
+ }
433
+ return items;
434
+ }
435
+
436
+ /** edit/write 未配置时默认关闭,其余工具默认开启;与 summary-utils 的规则保持一致。 */
437
+ const DEFAULT_DISABLED_TOOL_NAMES = new Set(["edit", "write"]);
438
+
439
+ /** 工具没被配置过时的默认开关。 */
440
+ export function defaultToolEnabled(toolName: string): boolean {
441
+ return !DEFAULT_DISABLED_TOOL_NAMES.has(toolName);
442
+ }
443
+
444
+ /** 读一个开关字段的当前值。 */
445
+ function readToggle(config: DistillPanelConfig, id: PanelFieldId): boolean {
446
+ switch (id) {
447
+ case PANEL_FIELD.enabled:
448
+ return config.enabled;
449
+ case PANEL_FIELD.summarizeErrors:
450
+ return config.summarizeErrors;
451
+ case PANEL_FIELD.renderEnabled:
452
+ return config.renderEnabled;
453
+ case PANEL_FIELD.renderShowPrompt:
454
+ return config.renderShowPrompt;
455
+ case PANEL_FIELD.renderShowResult:
456
+ return config.renderShowResult;
457
+ default:
458
+ return false;
459
+ }
460
+ }
461
+
462
+ /** 读一个数值字段的当前值,字符串形式(面板统一按字符串传递)。 */
463
+ function readNumber(config: DistillPanelConfig, id: PanelFieldId): string {
464
+ switch (id) {
465
+ case PANEL_FIELD.minChars:
466
+ return String(config.minChars);
467
+ case PANEL_FIELD.maxChars:
468
+ return String(config.maxChars);
469
+ case PANEL_FIELD.maxOutputChars:
470
+ return String(config.maxOutputChars);
471
+ case PANEL_FIELD.timeoutSeconds:
472
+ return String(config.timeoutSeconds);
473
+ case PANEL_FIELD.timeoutRetryCount:
474
+ return String(config.timeoutRetryCount);
475
+ case PANEL_FIELD.errorRetryCount:
476
+ return String(config.errorRetryCount);
477
+ case PANEL_FIELD.missedCompressionRatio:
478
+ return String(config.missedCompressionRatio);
479
+ default:
480
+ return "";
481
+ }
482
+ }
483
+
484
+ /** 写一个数值字段;不合法或参与校验失败时返回 undefined。 */
485
+ function writeNumber(config: DistillPanelConfig, id: PanelFieldId, value: string): DistillPanelConfig | undefined {
486
+ const normalized = value.trim();
487
+ const parsed = Number(normalized);
488
+ const isNonNegativeField =
489
+ id === PANEL_FIELD.timeoutRetryCount || id === PANEL_FIELD.errorRetryCount;
490
+ if (!isCustomValueValid(id, normalized)) return undefined;
491
+ if (!Number.isFinite(parsed)) return undefined;
492
+ if (isNonNegativeField && (!Number.isSafeInteger(parsed) || parsed < 0)) return undefined;
493
+ if (!isNonNegativeField && !isDecimalField(id) && parsed <= 0) return undefined;
494
+ if (isDecimalField(id) && parsed <= 0) return undefined;
495
+ switch (id) {
496
+ case PANEL_FIELD.minChars:
497
+ return { ...config, minChars: parsed };
498
+ case PANEL_FIELD.maxChars:
499
+ return { ...config, maxChars: parsed };
500
+ case PANEL_FIELD.maxOutputChars:
501
+ return { ...config, maxOutputChars: parsed };
502
+ case PANEL_FIELD.timeoutSeconds:
503
+ return { ...config, timeoutSeconds: parsed };
504
+ case PANEL_FIELD.timeoutRetryCount:
505
+ return { ...config, timeoutRetryCount: parsed };
506
+ case PANEL_FIELD.errorRetryCount:
507
+ return { ...config, errorRetryCount: parsed };
508
+ case PANEL_FIELD.missedCompressionRatio:
509
+ return { ...config, missedCompressionRatio: parsed };
510
+ default:
511
+ return undefined;
512
+ }
513
+ }
514
+
515
+ /**
516
+ * 把面板回传的一项改动写回配置;无法识别时返回 undefined,由调用方保持不变。
517
+ *
518
+ * 开关与枚举只接受候选表里的展示文本;数值与模型这两类还能接受手动输入的合法原值
519
+ * (二级列表顶部的「使用 “xxx”」就是走这条路),非法输入一律拒绝。
520
+ */
521
+ export function applyPanelChange(
522
+ config: DistillPanelConfig,
523
+ id: string,
524
+ value: string,
525
+ models: readonly ModelRef[] = [],
526
+ ): DistillPanelConfig | undefined {
527
+ const labels = panelToggleLabels();
528
+ const toolName = toolNameFromFieldId(id);
529
+ if (toolName !== undefined) {
530
+ const enabled = toggleValueFromLabel(value, labels);
531
+ if (enabled === undefined) return undefined;
532
+ return { ...config, tools: { ...config.tools, [toolName]: enabled } };
533
+ }
534
+
535
+ const field = PANEL_FIELDS.find((candidate) => candidate.id === id);
536
+ if (!field) return undefined;
537
+
538
+ if (field.kind === PANEL_KIND.toggle) {
539
+ const enabled = toggleValueFromLabel(value, labels);
540
+ if (enabled === undefined) return undefined;
541
+ switch (field.id) {
542
+ case PANEL_FIELD.enabled:
543
+ return { ...config, enabled };
544
+ case PANEL_FIELD.summarizeErrors:
545
+ return { ...config, summarizeErrors: enabled };
546
+ case PANEL_FIELD.renderEnabled:
547
+ return { ...config, renderEnabled: enabled };
548
+ case PANEL_FIELD.renderShowPrompt:
549
+ return { ...config, renderShowPrompt: enabled };
550
+ case PANEL_FIELD.renderShowResult:
551
+ return { ...config, renderShowResult: enabled };
552
+ default:
553
+ return undefined;
554
+ }
555
+ }
556
+
557
+ if (field.kind === PANEL_KIND.model) {
558
+ if (value === panelCurrentModelLabel()) return { ...config, model: "" };
559
+ const normalized = value.trim();
560
+ if (normalized !== "" && !isCustomValueValid(field.id, normalized)) return undefined;
561
+ return { ...config, model: normalized };
562
+ }
563
+
564
+ const stored = optionValueFromLabel(numberOptions(field.id), value) ?? value;
565
+ return writeNumber(config, field.id, stored);
566
+ }
567
+
568
+ /** 面板与宿主之间的接口;配置读写与生效动作都由入口注入。 */
569
+ export interface ConfigPanelHandlers {
570
+ /** 读取当前配置。 */
571
+ getConfig(): DistillPanelConfig;
572
+ /** 读取当前可用模型,供模型候选使用。 */
573
+ getModels(): readonly ModelRef[];
574
+ /** 读取当前可配置的工具名,供工具开关逐行生成。 */
575
+ getToolNames(): readonly string[];
576
+ /** 某一项被改动后调用,由入口负责保存并让本次会话立即生效。 */
577
+ onChange(config: DistillPanelConfig): void;
578
+ }
579
+
580
+ /** 打开配置面板;用户改动即时生效,Esc 关闭。 */
581
+ export async function openConfigPanel(
582
+ ctx: ExtensionCommandContext,
583
+ handlers: ConfigPanelHandlers,
584
+ ): Promise<void> {
585
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
586
+ const container = new Container();
587
+ container.addChild(new Text(theme.fg("accent", theme.bold(i18n.t("settingsTitle"))), 1, 1));
588
+
589
+ const itemCount = PANEL_FIELDS.length + handlers.getToolNames().length;
590
+ const settingsList = new SettingsList(
591
+ buildSettingItems(handlers.getConfig(), handlers.getModels(), handlers.getToolNames()),
592
+ Math.max(1, itemCount),
593
+ getSettingsListTheme(),
594
+ (id, newValue) => {
595
+ const next = applyPanelChange(handlers.getConfig(), id, newValue, handlers.getModels());
596
+ if (next) handlers.onChange(next);
597
+ },
598
+ () => done(undefined),
599
+ );
600
+ container.addChild(settingsList);
601
+
602
+ // ctx.ui.custom 要求返回 Component:整体布局交给 Container,键盘转给设置列表,
603
+ // 列表改了数据后必须 requestRender,否则画面停在上一帧。
604
+ return {
605
+ /** 面板整体按 Container 布局渲染。 */
606
+ render: (width: number) => container.render(width),
607
+ /** 无本地缓存,交给 Container 清理。 */
608
+ invalidate: () => container.invalidate(),
609
+ /** 键盘输入交给设置列表,并触发一次重绘。 */
610
+ handleInput: (data: string) => {
611
+ settingsList.handleInput(data);
612
+ tui.requestRender();
613
+ },
614
+ };
615
+ });
616
+ }