@foolsecret/pi-prompt 0.4.0 → 0.4.8

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/src/ui.ts CHANGED
@@ -1,113 +1,398 @@
1
1
  /**
2
- * 三轴设置抽屉(复刻 pi /settings 的交互效果):上下 DynamicBorder 彩色边框 +
3
- * 中间 SettingsList,选中行时说明栏随键盘上下切换(description 即"帮助栏/说明栏")。
2
+ * 分级设置抽屉(对齐姊妹项目 pi-pricer submenu 钻取范式):
3
+ * 根层 = 4 个分类(提示词 / 工具 / 上下文 / 其他),Enter 钻入子层,Esc 逐级返回。
4
+ * 上下 DynamicBorder 边框 + 动态面包屑标题 + 中间 SettingsList(说明栏随光标切换)。
4
5
  *
5
- * 设计规范见仓库根 DESIGN.md(design-md 格式):本模块是可复用部件,
6
- * 只负责"渲染 + 把变更转发给回调",不接触配置/台账(由调用方接线)。
6
+ * 三层切分(见仓库根 DESIGN.md):
7
+ * 纯函数层 buildRootItems / buildGroupItems / resolveSettingId —— 不依赖 TUI,可单测
8
+ * 表现层 PromptConfigDrawer —— 只组装组件 + 转发 (id,value) 给回调
9
+ * 接线层 PromptExtension —— 注入 readState / callbacks,负责写盘与持久化
7
10
  */
8
11
 
9
12
  import { DynamicBorder, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
10
- import { Container, SettingsList, Text, type SettingItem } from "@earendil-works/pi-tui";
13
+ import { Container, getKeybindings, matchesKey, SettingsList, Text, type SettingItem } from "@earendil-works/pi-tui";
11
14
  import type { DoMode, ShowMode, WriteMode } from "./modes.ts";
12
15
  import type { AxisName } from "./command.ts";
13
16
 
14
- /** 抽屉展示所需的当前状态:会话轴 + 默认轴(各三档) */
17
+ /** 抽屉展示所需的当前状态:三轴 + 开关 + 数值 */
15
18
  export interface PromptConfigDrawerState {
16
19
  session: { show: ShowMode; write: WriteMode; do: DoMode };
17
20
  defaults: { show: ShowMode; write: WriteMode; do: DoMode };
21
+ /** 布尔开关当前值 */
22
+ flags: {
23
+ peakUpgrade: boolean;
24
+ quietStartup: boolean;
25
+ hideStatus: boolean;
26
+ autoSample: boolean;
27
+ autoCompact: boolean;
28
+ };
29
+ /** 数值参数当前值 */
30
+ numbers: {
31
+ maxTokensCap: number;
32
+ /** 0 = 关 */
33
+ hysteresis: number;
34
+ autoCompactMaxTokens: number;
35
+ autoCompactPercent: number;
36
+ autoCompactMinTurns: number;
37
+ autoSampleInterval: number;
38
+ autoSampleMaxPerDay: number;
39
+ };
40
+ /** 工具输出上限(0 = 该工具不截) */
41
+ toolCaps: { grep: number; read: number; bash: number; default: number };
42
+ /** 工具输出截断总开关 */
43
+ toolOutputEnabled: boolean;
18
44
  }
19
45
 
20
46
  /** 抽屉各行的值域(show 含 auto 元档位,放第一项便于循环命中"自动") */
21
47
  const SHOW_VALUES: readonly string[] = ["auto", "normal", "lite", "full", "ultra"];
22
48
  const WRITE_VALUES: readonly string[] = ["minimal", "normal", "complete"];
23
49
  const DO_VALUES: readonly string[] = ["direct", "normal", "deep"];
50
+ /** 布尔开关:off 在前,循环先关后开 */
51
+ const BOOL_VALUES: readonly string[] = ["off", "on"];
24
52
 
25
- /** 设置项 id 的取值:会话轴 show|write|do;默认档 default-<轴> */
53
+ /** 设置项 id 的取值:会话轴 show|write|do;默认档 default-<轴>;开关 flag-<名>;数值 num-<名>;工具上限 cap-<工具> */
26
54
  const SESSION_IDS: readonly AxisName[] = ["show", "write", "do"];
27
55
  const DEFAULT_PREFIX = "default-";
56
+ const FLAG_PREFIX = "flag-";
57
+ const NUM_PREFIX = "num-";
58
+ const CAP_PREFIX = "cap-";
59
+
60
+ /** 分类 id(根层的 4 个钻取入口) */
61
+ export const GROUP_IDS = ["prompt", "tool", "context", "other"] as const;
62
+ export type GroupId = (typeof GROUP_IDS)[number];
63
+
64
+ /** 分类显示名(面包屑用同一份) */
65
+ const GROUP_LABELS: Record<GroupId, string> = {
66
+ prompt: "提示词",
67
+ tool: "工具",
68
+ context: "上下文",
69
+ other: "其他",
70
+ };
71
+
72
+ /** 把秒/数值格式化成人类可读(用于行尾 currentValue) */
73
+ function fmtNum(n: number): string {
74
+ if (n === 0) return "关";
75
+ if (n >= 1000 && n % 1000 === 0) return `${n / 1000}K`;
76
+ return String(n);
77
+ }
78
+
79
+ function fmtBool(b: boolean): string {
80
+ return b ? "on" : "off";
81
+ }
82
+
83
+ /** 工具上限的预设档(含"关"=0) */
84
+ const CAP_CHOICES: readonly number[] = [0, 4096, 8192, 16384, 32768, 65536];
85
+
86
+ /** 数值参数的预设档选项(值 → 显示名) */
87
+ export interface NumChoice {
88
+ value: number;
89
+ label: string;
90
+ }
91
+
92
+ /** 各数值参数的档位表(子菜单用) */
93
+ export const NUM_CHOICES: Record<string, readonly NumChoice[]> = {
94
+ hysteresis: [
95
+ { value: 0, label: "0(关迟滞,退回无状态 argmax)" },
96
+ { value: 0.25, label: "0.25(易换档)" },
97
+ { value: 0.5, label: "0.5(默认)" },
98
+ { value: 1, label: "1(最难换档,最保缓存)" },
99
+ ],
100
+ autoCompactMaxTokens: [
101
+ { value: 100000, label: "100K" },
102
+ { value: 200000, label: "200K" },
103
+ { value: 400000, label: "400K(默认)" },
104
+ { value: 800000, label: "800K" },
105
+ ],
106
+ autoCompactPercent: [
107
+ { value: 0.4, label: "40% 窗口" },
108
+ { value: 0.6, label: "60% 窗口(默认)" },
109
+ { value: 0.8, label: "80% 窗口" },
110
+ ],
111
+ autoCompactMinTurns: [
112
+ { value: 20, label: "20 轮" },
113
+ { value: 50, label: "50 轮(默认)" },
114
+ { value: 100, label: "100 轮" },
115
+ ],
116
+ maxTokensCap: [
117
+ { value: 0, label: "关(默认)" },
118
+ { value: 4096, label: "4096" },
119
+ { value: 8192, label: "8192" },
120
+ { value: 16384, label: "16384" },
121
+ ],
122
+ autoSampleInterval: [
123
+ { value: 20, label: "20 轮" },
124
+ { value: 50, label: "50 轮(默认)" },
125
+ { value: 100, label: "100 轮" },
126
+ ],
127
+ autoSampleMaxPerDay: [
128
+ { value: 1, label: "1 次/天" },
129
+ { value: 3, label: "3 次/天(默认)" },
130
+ { value: 10, label: "10 次/天" },
131
+ ],
132
+ };
133
+
134
+ /** 数值行的中文名(根层摘要与子层 label 共用) */
135
+ const NUM_LABELS: Record<string, string> = {
136
+ hysteresis: "换档迟滞(hysteresis)",
137
+ autoCompactMaxTokens: "压缩绝对上限 token",
138
+ autoCompactPercent: "压缩窗口百分比",
139
+ autoCompactMinTurns: "压缩最少轮数",
140
+ maxTokensCap: "max_tokens 上限",
141
+ autoSampleInterval: "抽样间隔(轮)",
142
+ autoSampleMaxPerDay: "当日抽样上限",
143
+ };
144
+
145
+ /** 开关行的中文名与说明 */
146
+ const FLAG_META: Record<string, { label: string; desc: string }> = {
147
+ peakUpgrade: {
148
+ label: "高峰升档(peakUpgrade)",
149
+ desc: "DeepSeek 高峰时段自动升档,用更省的注入换输出成本。",
150
+ },
151
+ autoCompact: {
152
+ label: "自动上下文压缩(autoCompact)",
153
+ desc: "上下文超过阈值且会话够长时自动压缩。默认关:压缩是投资,短会话压缩反而亏(缓存读很便宜)。",
154
+ },
155
+ autoSample: {
156
+ label: "auto 低频抽样(autoSample)",
157
+ desc: "定期采对照组样本用于校准节省估算。默认关。",
158
+ },
159
+ quietStartup: { label: "安静启动(quietStartup)", desc: "启动时不打印提示信息。" },
160
+ hideStatus: { label: "隐藏状态栏(hideStatus)", desc: "不在底部状态栏显示 pi-prompt 档位。" },
161
+ };
162
+
163
+ /** 分类说明(根层 description) */
164
+ const GROUP_DESC: Record<GroupId, string> = {
165
+ prompt: "注入系统提示词的三轴:控制模型回话的风格、代码量、行为力度(会话档 + 新会话默认档)。",
166
+ tool: "请求形状与工具结果:高峰升档、max_tokens 上限、工具输出截断上限。",
167
+ context: "上下文长度与缓存稳定性:自动压缩开关与阈值、换档迟滞。",
168
+ other: "观测与界面偏好:auto 抽样校准、启动与状态栏显示。",
169
+ };
170
+
171
+ /** 分类 → 该组包含的行 id(纯数据,buildGroupItems 与测试共用) */
172
+ export const GROUP_MEMBERS: Record<GroupId, readonly string[]> = {
173
+ prompt: ["show", "write", "do", "default-show", "default-write", "default-do"],
174
+ tool: ["flag-peakUpgrade", "num-maxTokensCap", "flag-toolOutput", "cap-grep", "cap-read", "cap-bash", "cap-default"],
175
+ context: ["flag-autoCompact", "num-autoCompactMaxTokens", "num-autoCompactPercent", "num-autoCompactMinTurns", "num-hysteresis"],
176
+ other: ["flag-autoSample", "num-autoSampleInterval", "num-autoSampleMaxPerDay", "flag-quietStartup", "flag-hideStatus"],
177
+ };
178
+
179
+ /** 构造单个三轴行 */
180
+ function axisItem(axis: AxisName, prefix: string, state: PromptConfigDrawerState): SettingItem {
181
+ const isDefault = prefix === DEFAULT_PREFIX;
182
+ const current = isDefault ? state.defaults[axis] : state.session[axis];
183
+ const values = axis === "show" ? SHOW_VALUES : axis === "write" ? WRITE_VALUES : DO_VALUES;
184
+ const scope = isDefault ? "默认" : "会话";
185
+ const base = axis === "show" ? "风格" : axis === "write" ? "代码" : "行动";
186
+ const detail =
187
+ axis === "show"
188
+ ? "auto 按成本函数每轮自动选档;ultra 电报式最省、full 常规精炼、lite 轻柔、normal 不注入。"
189
+ : axis === "write"
190
+ ? "minimal 只交付最小可跑实现、normal 常规、complete 富注释带文档。"
191
+ : "direct 收敛最小步骤、normal 常规、deep 主动查证根因与边界。";
192
+ return {
193
+ id: `${prefix}${axis}`,
194
+ label: `${scope} · ${base}(${axis})`,
195
+ currentValue: current,
196
+ values: [...values],
197
+ description: `${detail}${isDefault ? "(仅影响后续会话)" : ""}`,
198
+ };
199
+ }
200
+
201
+ /** 构造单个开关行 */
202
+ function flagItem(name: string, state: PromptConfigDrawerState): SettingItem {
203
+ const meta = FLAG_META[name]!;
204
+ const current = name === "toolOutput" ? state.toolOutputEnabled : Boolean(state.flags[name as keyof typeof state.flags]);
205
+ return {
206
+ id: `${FLAG_PREFIX}${name}`,
207
+ label: name === "toolOutput" ? "工具输出截断(toolOutput)" : meta.label,
208
+ currentValue: fmtBool(current),
209
+ values: [...BOOL_VALUES],
210
+ description:
211
+ name === "toolOutput"
212
+ ? "在工具结果创建时截断到各工具上限(缓存友好)。默认关:pi 已自带 50KB 上限,再收紧会改变模型看到的内容。"
213
+ : meta.desc,
214
+ };
215
+ }
216
+
217
+ /** 构造单个数值行(Enter 打开预设档子菜单) */
218
+ function numItem(name: string, state: PromptConfigDrawerState, onPick: (name: string, value: number) => void): SettingItem {
219
+ const current = state.numbers[name as keyof typeof state.numbers];
220
+ const choices = NUM_CHOICES[name] ?? [];
221
+ return {
222
+ id: `${NUM_PREFIX}${name}`,
223
+ label: NUM_LABELS[name] ?? name,
224
+ currentValue: fmtNum(current),
225
+ description: "Enter 选择预设档位。",
226
+ submenu: (_v, done) => {
227
+ const sub: SettingItem[] = choices.map((c) => ({
228
+ id: `${NUM_PREFIX}${name}#${c.value}`,
229
+ label: c.label,
230
+ currentValue: c.value === current ? "← 当前" : "",
231
+ }));
232
+ return new SettingsList(
233
+ sub,
234
+ Math.min(sub.length + 4, 12),
235
+ getSettingsListTheme(),
236
+ (id) => {
237
+ const picked = Number(String(id).split("#")[1]);
238
+ if (!Number.isFinite(picked)) return;
239
+ onPick(name, picked);
240
+ done(String(picked));
241
+ },
242
+ () => done(undefined),
243
+ { enableSearch: true },
244
+ );
245
+ },
246
+ };
247
+ }
248
+
249
+ /** 构造单个工具上限行 */
250
+ function capItem(tool: "grep" | "read" | "bash" | "default", state: PromptConfigDrawerState): SettingItem {
251
+ const current = state.toolCaps[tool];
252
+ return {
253
+ id: `${CAP_PREFIX}${tool}`,
254
+ label: `工具输出上限 · ${tool}`,
255
+ currentValue: fmtNum(current),
256
+ values: CAP_CHOICES.map((n) => String(n)),
257
+ description:
258
+ tool === "read"
259
+ ? "read 是模型主动要看的内容,砍太狠会引发重读 → 反而增加输出成本,建议 ≥16K。"
260
+ : tool === "grep"
261
+ ? "grep 匹配行冗余度高,收紧收益最大。"
262
+ : "0 = 不截该工具(仍受 pi 自带 50KB 上限约束)。",
263
+ };
264
+ }
28
265
 
29
266
  /**
30
- * 依据当前状态生成设置项列表(纯函数,不依赖 TUI,可单测)。
31
- * 每项都有 description —— SettingsList 会把选中项的 description 渲染在
32
- * 提示行上方,这就是"说明栏随光标切换"的机制。
267
+ * 根层 4 个分类项(每项 Enter 钻入子层)。
268
+ * currentValue 为分类摘要(如 "show=auto"),便于不钻入也能看到关键状态。
33
269
  */
34
- export function buildSettingItems(state: PromptConfigDrawerState): SettingItem[] {
35
- const session: SettingItem[] = [
36
- {
37
- id: "show",
38
- label: "会话输出风格(show)",
39
- currentValue: state.session.show,
40
- values: [...SHOW_VALUES],
41
- description:
42
- "输出风格:auto 按成本函数每轮自动选档;ultra 电报式最省、full 常规精炼、lite 轻柔、normal 不注入(基础行为)。",
43
- },
44
- {
45
- id: "write",
46
- label: "会话代码量(write)",
47
- currentValue: state.session.write,
48
- values: [...WRITE_VALUES],
49
- description: "代码写量:minimal 只交付最小可跑实现、normal 常规、complete 富注释带文档。",
50
- },
51
- {
52
- id: "do",
53
- label: "会话行为力度(do)",
54
- currentValue: state.session.do,
55
- values: [...DO_VALUES],
56
- description: "行为力度:direct 收敛最小步骤、normal 常规、deep 主动查证根因与边界。",
57
- },
58
- ];
59
- const defaults: SettingItem[] = [
60
- {
61
- id: `${DEFAULT_PREFIX}show`,
62
- label: "默认输出风格(show)",
63
- currentValue: state.defaults.show,
64
- values: [...SHOW_VALUES],
65
- description: "新会话默认的输出风格(后续会话生效,不影响当前会话)。",
66
- },
67
- {
68
- id: `${DEFAULT_PREFIX}write`,
69
- label: "默认代码量(write)",
70
- currentValue: state.defaults.write,
71
- values: [...WRITE_VALUES],
72
- description: "新会话默认的代码写量。",
73
- },
74
- {
75
- id: `${DEFAULT_PREFIX}do`,
76
- label: "默认行为力度(do)",
77
- currentValue: state.defaults.do,
78
- values: [...DO_VALUES],
79
- description: "新会话默认的行为力度。",
80
- },
81
- ];
82
- return [...session, ...defaults];
270
+ export function buildRootItems(state: PromptConfigDrawerState): SettingItem[] {
271
+ const promptSummary = `风格=${state.session.show}`;
272
+ const toolSummary = `工具截断=${fmtBool(state.toolOutputEnabled)}`;
273
+ const ctxSummary = `压缩=${fmtBool(state.flags.autoCompact)}`;
274
+ const otherSummary = `抽样=${fmtBool(state.flags.autoSample)}`;
275
+ const summary: Record<GroupId, string> = {
276
+ prompt: promptSummary,
277
+ tool: toolSummary,
278
+ context: ctxSummary,
279
+ other: otherSummary,
280
+ };
281
+ return GROUP_IDS.map((id) => ({
282
+ id: `group-${id}`,
283
+ label: GROUP_LABELS[id],
284
+ currentValue: summary[id],
285
+ description: GROUP_DESC[id],
286
+ }));
287
+ }
288
+
289
+ /** 子层:某分类下的所有设置行(纯函数,可单测) */
290
+ export function buildGroupItems(
291
+ group: GroupId,
292
+ state: PromptConfigDrawerState,
293
+ onNumPick: (name: string, value: number) => void = () => {},
294
+ ): SettingItem[] {
295
+ return GROUP_MEMBERS[group].map((id) => {
296
+ if (id === "show" || id === "write" || id === "do") return axisItem(id, "", state);
297
+ if (id.startsWith(DEFAULT_PREFIX)) return axisItem(id.slice(DEFAULT_PREFIX.length) as AxisName, DEFAULT_PREFIX, state);
298
+ if (id.startsWith(FLAG_PREFIX)) return flagItem(id.slice(FLAG_PREFIX.length), state);
299
+ if (id.startsWith(NUM_PREFIX)) return numItem(id.slice(NUM_PREFIX.length), state, onNumPick);
300
+ if (id.startsWith(CAP_PREFIX)) return capItem(id.slice(CAP_PREFIX.length) as "grep" | "read" | "bash" | "default", state);
301
+ throw new Error(`未知行 id: ${id}`);
302
+ });
83
303
  }
84
304
 
85
- /** 设置项 id → 是否为默认档行,以及对应的轴 */
86
- export function resolveSettingId(id: string): { axis: AxisName; isDefault: boolean } | null {
87
- if (SESSION_IDS.includes(id as AxisName)) return { axis: id as AxisName, isDefault: false };
305
+ /** 分类显示名(接线层拼面包屑标题用) */
306
+ export function groupLabel(group: GroupId): string {
307
+ return GROUP_LABELS[group];
308
+ }
309
+
310
+ /** 设置项 id → 分发目标(唯一分发点;未知 id 返回 null 即丢弃) */
311
+ export type SettingTarget =
312
+ | { kind: "session-axis"; axis: AxisName }
313
+ | { kind: "default-axis"; axis: AxisName }
314
+ | { kind: "flag"; name: string }
315
+ | { kind: "number"; name: string }
316
+ | { kind: "cap"; tool: "grep" | "read" | "bash" | "default" };
317
+
318
+ export function resolveSettingId(id: string): SettingTarget | null {
319
+ if (SESSION_IDS.includes(id as AxisName)) return { kind: "session-axis", axis: id as AxisName };
88
320
  if (id.startsWith(DEFAULT_PREFIX)) {
89
321
  const axis = id.slice(DEFAULT_PREFIX.length) as AxisName;
90
- if (SESSION_IDS.includes(axis)) return { axis, isDefault: true };
322
+ if (SESSION_IDS.includes(axis)) return { kind: "default-axis", axis };
323
+ }
324
+ if (id.startsWith(FLAG_PREFIX)) return { kind: "flag", name: id.slice(FLAG_PREFIX.length) };
325
+ if (id.startsWith(NUM_PREFIX)) return { kind: "number", name: id.slice(NUM_PREFIX.length) };
326
+ if (id.startsWith(CAP_PREFIX)) {
327
+ const tool = id.slice(CAP_PREFIX.length);
328
+ if (tool === "grep" || tool === "read" || tool === "bash" || tool === "default") return { kind: "cap", tool };
91
329
  }
92
330
  return null;
93
331
  }
94
332
 
95
- /** 抽屉所需的回调接线(由 PromptExtension 提供,注入以便单测) */
333
+ /**
334
+ * 抽屉所需的回调接线(由 PromptExtension 提供,注入以便单测)。
335
+ * 返回值 = 是否变更成功(写入草稿),供抽屉刷新与状态栏显示。
336
+ * ⚠ `编辑只改草稿`——落盘只由 onSave 触发(Ctrl+S)。
337
+ */
96
338
  export interface PromptConfigDrawerCallbacks {
97
- onSessionAxisChange: (axis: AxisName, value: string) => void;
98
- onDefaultAxisChange: (axis: AxisName, value: string) => void;
339
+ onSessionAxisChange: (axis: AxisName, value: string) => boolean;
340
+ onDefaultAxisChange: (axis: AxisName, value: string) => boolean;
341
+ onFlagChange: (name: string, value: boolean) => boolean;
342
+ onNumberChange: (name: string, value: number) => boolean;
343
+ onCapChange: (tool: "grep" | "read" | "bash" | "default", value: number) => boolean;
344
+ /** Ctrl+S:把草稿落盘 */
345
+ onSave: () => { ok: boolean; reason: string };
346
+ /** Ctrl+R:丢弃未保存改动 */
347
+ onReset: () => void;
348
+ }
349
+
350
+ /** 抽屉主题形参(与 pi-pricer 对齐) */
351
+ interface DrawerTheme {
352
+ fg: (color: string, text: string) => string;
353
+ bold: (text: string) => string;
99
354
  }
100
355
 
101
356
  /**
102
- * 三轴设置抽屉:open 时在 ctx.ui 上开一个 /settings 同款抽屉;
357
+ * 分级设置抽屉:open 时在 ctx.ui 上开一个 pi-pricer 同款抽屉。
103
358
  * 无 TUI(非交互模式)时交给 onUnavailable 回退。
359
+ *
360
+ * 保存模型(**草稿 + Ctrl+S**,对齐 pi-pricer):
361
+ * 抽屉内所有编辑只改**内存草稿**(由接线层的 PromptDraft 持有),
362
+ * 状态栏常驻显示 `● 未保存改动(面)`;Ctrl+S 落盘后转 `✓ 已保存`。
363
+ * 根层 Esc 且有未保存改动时**拦截退出**:第一次只提示,第二次才放弃退出。
364
+ *
365
+ * 刷新模型:readState() 与 SettingsList.items 都是构造时**快照**,
366
+ * 故变更后必须重建当前层与根层列表(否则根层摘要 stale——曾出现
367
+ * "改了值但返回上层看到的还是旧值");重建后用 selectItem() 保住光标。
368
+ *
369
+ * 键盘拦截必须用 matchesKey 而非裸字节:pi-tui 在增强键盘协议下把 Ctrl+S
370
+ * 编码为 CSI-u / modifyOtherKeys 序列,裸比较 \x13 永远不命中(保存静默失效)。
104
371
  */
105
372
  export class PromptConfigDrawer {
373
+ /** 当前所在分类(null = 根层) */
374
+ private activeGroup: GroupId | null = null;
375
+ /** 当前层列表引用(重建时替换) */
376
+ private activeList: SettingsList | null = null;
377
+ /** 是否已有未保存改动(由接线层草稿状态同步;决定 Esc 是否拦截) */
378
+ private readonly draftView: DrawerDraftView;
379
+
106
380
  constructor(
107
381
  private readonly readState: () => PromptConfigDrawerState,
108
382
  private readonly callbacks: PromptConfigDrawerCallbacks,
109
383
  private readonly onUnavailable: (context: unknown) => void,
110
- ) {}
384
+ draftView?: DrawerDraftView,
385
+ ) {
386
+ this.draftView = draftView ?? { isDirty: () => false, changedAreas: () => [] };
387
+ }
388
+
389
+ private isDirty(): boolean {
390
+ return this.draftView.isDirty();
391
+ }
392
+
393
+ private changedAreas(): string[] {
394
+ return this.draftView.changedAreas();
395
+ }
111
396
 
112
397
  /** 打开抽屉;无 custom UI 时走回退分支 */
113
398
  async open(context: unknown): Promise<void> {
@@ -116,41 +401,200 @@ export class PromptConfigDrawer {
116
401
  this.onUnavailable(context);
117
402
  return;
118
403
  }
119
- const items = buildSettingItems(this.readState());
120
404
  await (ui.custom as (factory: unknown) => Promise<unknown>)(
121
- (tui: unknown, theme: { fg: (color: string, text: string) => string; bold: (text: string) => string }, _kb: unknown, done: (result?: undefined) => void) => {
122
- const container = new Container();
123
- container.addChild(new DynamicBorder((s: string) => theme.fg("borderAccent", s)));
124
- container.addChild(
125
- new Text(theme.fg("accent", theme.bold("pi-prompt 设置")), 1, 1),
126
- );
127
- const settingsList = new SettingsList(
128
- items,
129
- Math.min(items.length + 4, 12),
130
- getSettingsListTheme(),
131
- (id, newValue) => this.handleChange(String(id), String(newValue)),
132
- () => done(undefined),
133
- { enableSearch: true },
134
- );
135
- container.addChild(settingsList);
136
- container.addChild(new DynamicBorder((s: string) => theme.fg("borderAccent", s)));
405
+ (tui: unknown, theme: DrawerTheme, _kb: unknown, done: (result?: undefined) => void) =>
406
+ this.buildScene(theme, () => done(undefined)),
407
+ );
408
+ }
409
+
410
+ /**
411
+ * 组装抽屉场景:边框 + 面包屑标题 + 当前层列表 + 状态栏 + 下边框。
412
+ * Esc 归属用**显式深度计数器**(不靠标题反推:标题是展示层状态,
413
+ * 用它决定 Esc 归属会把展示与逻辑耦合)。
414
+ */
415
+ private buildScene(theme: DrawerTheme, close: () => void): unknown {
416
+ const ROOT_TITLE = "pi-prompt 设置";
417
+ const container = new Container();
418
+ const title = new Text(theme.fg("accent", theme.bold(ROOT_TITLE)), 1, 1);
419
+ const statusText = new Text("", 1, 0);
420
+
421
+ /** 深度:根层 0,每进一级 +1(决定 Esc 是"返回上级"还是"退出") */
422
+ let depth = 0;
423
+ /** Esc 已提示过"放弃退出"?有未保存改动时第一次 Esc 只提示,第二次才退 */
424
+ let escHintShown = false;
425
+
426
+ const refreshStatus = (message?: string): void => {
427
+ const left = this.isDirty()
428
+ ? theme.fg("warning", `● 未保存改动(${this.changedAreas().join("、")})`)
429
+ : theme.fg("success", "✓ 已保存");
430
+ const detail = message ? ` ${message}` : "";
431
+ const keys = theme.fg("dim", " Ctrl+S 保存 · Ctrl+R 重置 · Esc 返回");
432
+ statusText.setText(`${left}${detail}${keys}`);
433
+ container.invalidate();
434
+ };
435
+
436
+ const setTitle = (text: string): void => {
437
+ title.setText(theme.fg("accent", theme.bold(text)));
438
+ container.invalidate();
439
+ };
440
+
441
+ /** 用新列表替换 container 里的旧列表(原地换,保持边框/标题/状态栏顺序) */
442
+ const swapList = (next: SettingsList, prev: SettingsList | null): void => {
443
+ if (prev) container.removeChild(prev);
444
+ container.removeChild(statusText);
445
+ container.addChild(next);
446
+ container.addChild(statusText);
447
+ this.activeList = next;
448
+ container.invalidate();
449
+ };
450
+
451
+ const sceneCtx: SceneCtx = { theme, setTitle, rootTitle: ROOT_TITLE, refreshStatus, swapList, close, enterLevel: () => { depth += 1; }, leaveLevel: () => { depth = Math.max(0, depth - 1); } };
452
+
453
+ this.activeList = this.buildLevel(sceneCtx);
454
+ container.addChild(new DynamicBorder((s: string) => theme.fg("borderAccent", s)));
455
+ container.addChild(title);
456
+ container.addChild(this.activeList);
457
+ container.addChild(statusText);
458
+ container.addChild(new DynamicBorder((s: string) => theme.fg("borderAccent", s)));
459
+ refreshStatus();
460
+
461
+ return {
462
+ render: (w: number) => container.render(w),
463
+ invalidate: () => container.invalidate(),
464
+ handleInput: (data: unknown) => {
465
+ const key = data as string;
466
+ // Ctrl+S / Ctrl+R 任意层级可用,优先于列表导航
467
+ if (matchesKey(key, "ctrl+s")) {
468
+ const result = this.callbacks.onSave();
469
+ escHintShown = false;
470
+ refreshStatus(result.ok ? "已保存到 ~/.pi/pi-prompt.json" : `保存失败:${result.reason}`);
471
+ return;
472
+ }
473
+ if (matchesKey(key, "ctrl+r")) {
474
+ this.callbacks.onReset();
475
+ escHintShown = false;
476
+ // 重置后当前层列表要按新状态重建
477
+ swapList(this.buildLevel(sceneCtx), this.activeList);
478
+ refreshStatus("已丢弃未保存改动");
479
+ return;
480
+ }
481
+ // 根层 Esc + 有未保存改动:第一次提示,第二次放弃退出
482
+ if (getKeybindings().matches(key, "tui.select.cancel") && this.isDirty() && depth === 0) {
483
+ if (!escHintShown) {
484
+ escHintShown = true;
485
+ refreshStatus(theme.fg("warning", "有未保存改动:Ctrl+S 保存 / Ctrl+R 重置 / 再按 Esc 放弃退出"));
486
+ return;
487
+ }
488
+ close();
489
+ return;
490
+ }
491
+ this.activeList?.handleInput?.(key);
492
+ },
493
+ };
494
+ }
495
+
496
+ /** 构建当前层(根层或某个分类子层)的 SettingsList */
497
+ private buildLevel(ctx: SceneCtx): SettingsList {
498
+ if (this.activeGroup === null) {
499
+ const items: SettingItem[] = buildRootItems(this.readState()).map((item) => {
500
+ const group = item.id.replace(/^group-/, "") as GroupId;
137
501
  return {
138
- render: (w: number) => container.render(w),
139
- invalidate: () => container.invalidate(),
140
- handleInput: (data: unknown) => settingsList.handleInput?.(data as string),
502
+ ...item,
503
+ submenu: (_currentValue, done) => {
504
+ this.activeGroup = group;
505
+ ctx.enterLevel();
506
+ ctx.setTitle(`${ctx.rootTitle} · ${groupLabel(group)}`);
507
+ const child = this.buildLevel(ctx);
508
+ ctx.swapList(child, this.activeList);
509
+ ctx.refreshStatus(`${groupLabel(group)}组`);
510
+ return child;
511
+ },
141
512
  };
142
- },
513
+ });
514
+ return new SettingsList(
515
+ items,
516
+ Math.min(items.length + 4, 12),
517
+ getSettingsListTheme(),
518
+ () => {},
519
+ ctx.close,
520
+ { enableSearch: true },
521
+ );
522
+ }
523
+ const group = this.activeGroup;
524
+ const items = buildGroupItems(group, this.readState());
525
+ return new SettingsList(
526
+ items,
527
+ Math.min(items.length + 4, 16),
528
+ getSettingsListTheme(),
529
+ (id, newValue) => this.handleChange(String(id), String(newValue), ctx),
530
+ // 子层 Esc:回根层(而不是关抽屉)
531
+ () => this.goRoot(ctx),
532
+ { enableSearch: true },
143
533
  );
144
534
  }
145
535
 
146
- /** 行变更分发:会话行 vs 默认档行,分别走对应回调 */
147
- private handleChange(id: string, value: string): void {
148
- const resolved = resolveSettingId(id);
149
- if (!resolved) return;
150
- if (resolved.isDefault) {
151
- this.callbacks.onDefaultAxisChange(resolved.axis, value);
152
- } else {
153
- this.callbacks.onSessionAxisChange(resolved.axis, value);
536
+ /** 回到根层:重置层级、标题、列表 */
537
+ private goRoot(ctx: SceneCtx): void {
538
+ this.activeGroup = null;
539
+ ctx.leaveLevel();
540
+ ctx.setTitle(ctx.rootTitle);
541
+ const root = this.buildLevel(ctx);
542
+ ctx.swapList(root, this.activeList);
543
+ ctx.refreshStatus();
544
+ }
545
+
546
+ /** 行变更分发:只改草稿(Ctrl+S 才落盘),随后重建当前层让值可见 */
547
+ private handleChange(id: string, value: string, ctx: SceneCtx): void {
548
+ const target = resolveSettingId(id);
549
+ if (!target) return;
550
+ let ok = false;
551
+ switch (target.kind) {
552
+ case "session-axis":
553
+ ok = this.callbacks.onSessionAxisChange(target.axis, value);
554
+ break;
555
+ case "default-axis":
556
+ ok = this.callbacks.onDefaultAxisChange(target.axis, value);
557
+ break;
558
+ case "flag":
559
+ ok = this.callbacks.onFlagChange(target.name, value === "on");
560
+ break;
561
+ case "number": {
562
+ const n = Number(value);
563
+ if (!Number.isFinite(n)) return;
564
+ ok = this.callbacks.onNumberChange(target.name, n);
565
+ break;
566
+ }
567
+ case "cap": {
568
+ const n = Number(value);
569
+ if (!Number.isFinite(n)) return;
570
+ ok = this.callbacks.onCapChange(target.tool, n);
571
+ break;
572
+ }
154
573
  }
574
+ if (!ok) return;
575
+ const rebuilt = this.buildLevel(ctx);
576
+ rebuilt.selectItem(id);
577
+ ctx.swapList(rebuilt, this.activeList);
578
+ ctx.refreshStatus();
155
579
  }
156
- }
580
+ }
581
+
582
+ /** 场景闭包上下文(标题/状态栏/列表替换/层级/关闭) */
583
+ interface SceneCtx {
584
+ theme: DrawerTheme;
585
+ setTitle: (t: string) => void;
586
+ rootTitle: string;
587
+ refreshStatus: (msg?: string) => void;
588
+ swapList: (next: SettingsList, prev: SettingsList | null) => void;
589
+ /** 进入子层:深度 +1(决定 Esc 归属) */
590
+ enterLevel: () => void;
591
+ /** 回到上级:深度 -1 */
592
+ leaveLevel: () => void;
593
+ close: () => void;
594
+ }
595
+
596
+ /** 抽屉可注入的草稿状态读取(供接线层同步 dirty / changedAreas) */
597
+ export interface DrawerDraftView {
598
+ isDirty: () => boolean;
599
+ changedAreas: () => string[];
600
+ }