@yinan_chen/pi-claude-code-ui 0.8.9 → 0.9.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.
- package/README.md +22 -6
- package/package.json +1 -1
- package/src/index.ts +80 -35
- package/src/settings.ts +327 -0
- package/src/skin-setting-panel.ts +347 -0
package/README.md
CHANGED
|
@@ -32,10 +32,26 @@ pi -e npm:@yinan_chen/pi-claude-code-ui
|
|
|
32
32
|
- **子代理面板** — 后台 agent 状态一目了然
|
|
33
33
|
- **工作状态提示** — working message / running effect
|
|
34
34
|
- **只读工具美化** — read/bash/grep/find/ls 结果渲染
|
|
35
|
+
- **设置面板** — `/skin-setting` 即改即存调整全部皮肤配置,`/skin-setting default` 一键恢复默认(v0.9.0)
|
|
35
36
|
|
|
36
37
|
## 配置
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
### 推荐方式:`/skin-setting` 设置面板
|
|
40
|
+
|
|
41
|
+
在 pi 会话里输入 `/skin-setting` 打开居中 overlay 设置面板,即改即存——每次改动校验后立即写入配置文件并刷新面板行,没有"保存/放弃"负担:
|
|
42
|
+
|
|
43
|
+
- 内置搜索过滤,25 个配置键快速定位;每行附中文说明与默认值
|
|
44
|
+
- 枚举/布尔键回车在合法值间循环切换,不会选错;数字/字符串键回车进入输入框,带范围校验(如 0–100、0–4)
|
|
45
|
+
- 大多数键是热键,改动关闭面板后整屏重绘即刻可见;加载期固化的冷键常驻 `⟳ 需 /reload 生效` 标注,改动时会再提醒一次
|
|
46
|
+
- `foreignCardSkip` 黑名单只读展示,看得见状态又不会误编辑
|
|
47
|
+
- `/skin-setting default`:经确认对话框一键恢复全部默认(只清除本皮肤的配置键,文件里手写的其他内容保留)
|
|
48
|
+
- 非 TUI 模式(RPC / JSON / print)下运行会提示配置文件路径,请改用文件方式
|
|
49
|
+
|
|
50
|
+
任何时刻可用 **F5** 整屏重绘(排残影的习惯操作)。
|
|
51
|
+
|
|
52
|
+
### 手改配置文件
|
|
53
|
+
|
|
54
|
+
也可以直接编辑 `~/.pi/agent/lean-tidy.json`(不存在时全部使用默认值)。手改后需 `/reload` 生效;设置面板的改动则是热键即时生效、冷键(下表标 `⟳` 者)需 `/reload`:
|
|
39
55
|
|
|
40
56
|
```json
|
|
41
57
|
{
|
|
@@ -71,20 +87,20 @@ pi -e npm:@yinan_chen/pi-claude-code-ui
|
|
|
71
87
|
|
|
72
88
|
| 键 | 默认 | 说明 |
|
|
73
89
|
|---|---|---|
|
|
74
|
-
| `profile` | `"claude"` | 整体风格:`claude` / `native`(pi 原生) |
|
|
90
|
+
| `profile` ⟳ | `"claude"` | 整体风格:`claude` / `native`(pi 原生) |
|
|
75
91
|
| `skillRendering` | `"native"` | skill 块样式:pi 原生折叠行 / `claude` 两行风格 |
|
|
76
|
-
| `subagentRendering` | `"claude"` | 子代理完成通知:`claude` 分支风格 / `native` 保留包内面板 |
|
|
92
|
+
| `subagentRendering` ⟳ | `"claude"` | 子代理完成通知:`claude` 分支风格 / `native` 保留包内面板 |
|
|
77
93
|
| `detailsBackground` | `"selectedBg"` | 详情背景色(主题色键名,可选 `customMessageBg`/`toolPendingBg`/`toolSuccessBg`/`toolErrorBg`/`""`) |
|
|
78
94
|
| `bash/code/errorPreviewLines` | `0`/`0`/`0` | 三类工具结果的预览行数(0–100;0 = 默认折叠) |
|
|
79
95
|
| `liveThinking` | `true` | 思考过程流式展示 |
|
|
80
96
|
| `thinkingTailLines` | `5` | 思考尾行数 |
|
|
81
97
|
| `foldAnimMs` | `240` | 折叠动画时长(ms,0 关闭) |
|
|
82
|
-
| `stickyIncludeCustom` | `false` | 吸附泡是否包含扩展注入/跨会话转发的旁路消息 |
|
|
83
|
-
| `skillResumePreview` | `true` | `/resume` 列表预览是否折回 `[skill] 名` 单行 |
|
|
98
|
+
| `stickyIncludeCustom` ⟳ | `false` | 吸附泡是否包含扩展注入/跨会话转发的旁路消息 |
|
|
99
|
+
| `skillResumePreview` ⟳ | `true` | `/resume` 列表预览是否折回 `[skill] 名` 单行 |
|
|
84
100
|
| `resultPrefix` | `"└"` | 工具结果行拐角前缀(制表符族,等宽字体最稳;可换 `↳` 或 Claude Code 官方的 `⎿`,后两者在部分字体下会变宽) |
|
|
85
101
|
| `resultPrefixGap` | `1` | 前缀符号与内容之间的空格数(0–4) |
|
|
86
102
|
| `normalizeForeignRenderers` | `true` | 第三方扩展卡片输出里行首 `⎿` 自动规范化为 `resultPrefix`(任何未适配扩展的渲染都能统一风格;关闭可看第三方原版) |
|
|
87
|
-
| `workflowRendering` | `"claude"` | workflow 完成卡片样式:`● Workflow(name)` 风格 / `native` 保留包内卡片。分发层拦截实现,不受包加载顺序影响 |
|
|
103
|
+
| `workflowRendering` ⟳ | `"claude"` | workflow 完成卡片样式:`● Workflow(name)` 风格 / `native` 保留包内卡片。分发层拦截实现,不受包加载顺序影响 |
|
|
88
104
|
| `foreignCardStyle` | `"generic"` | 未适配扩展的卡片:自动接管为通用 `● 卡片`(零配置);`symbols` = 仅统一符号不接管布局 |
|
|
89
105
|
| `foreignCardSkip` | `[]` | 不接管的 customType 黑名单(交互型第三方卡片异常时按类型跳过) |
|
|
90
106
|
| `diff.diffViewMode` | `"auto"` | diff 视图:`auto`/`split`/`unified` |
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -36,6 +36,8 @@ import registerWorkingMessage from "./working-message.ts";
|
|
|
36
36
|
// Vendored rich diff (MIT; see vendor/diff/ATTRIBUTION.md).
|
|
37
37
|
import { renderRichToolResult, WriteExecutionMetadataStore } from "./vendor/diff/index.ts";
|
|
38
38
|
import { DEFAULT_TOOL_DISPLAY_CONFIG, type ToolDisplayConfig } from "./vendor/diff-config.ts";
|
|
39
|
+
import { SETTING_BY_ID, applyRuntimeDefaults, resetConfigFile, topLevelDefaults } from "./settings.ts";
|
|
40
|
+
import { handleSkinSettingCommand } from "./skin-setting-panel.ts";
|
|
39
41
|
|
|
40
42
|
const LOG = "/tmp/lean-tidy.log";
|
|
41
43
|
const DEBUG = (process.env.PI_LEAN_TIDY_DEBUG || "").trim() === "1";
|
|
@@ -98,26 +100,8 @@ interface Config {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
function loadConfig(): Config {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
skillRendering: "native",
|
|
104
|
-
subagentRendering: "claude",
|
|
105
|
-
detailsBackground: "selectedBg",
|
|
106
|
-
bashPreviewLines: 0,
|
|
107
|
-
codePreviewLines: 0,
|
|
108
|
-
errorPreviewLines: 0,
|
|
109
|
-
liveThinking: true,
|
|
110
|
-
thinkingTailLines: 5,
|
|
111
|
-
foldAnimMs: 240,
|
|
112
|
-
stickyIncludeCustom: false,
|
|
113
|
-
skillResumePreview: true,
|
|
114
|
-
resultPrefix: "└",
|
|
115
|
-
resultPrefixGap: 1,
|
|
116
|
-
normalizeForeignRenderers: true,
|
|
117
|
-
workflowRendering: "claude",
|
|
118
|
-
foreignCardStyle: "generic",
|
|
119
|
-
foreignCardSkip: [],
|
|
120
|
-
};
|
|
103
|
+
// 默认值单一来源:18 个顶层键的初始值取自 settings.ts 的元数据(与面板/恢复默认共用同一份)。
|
|
104
|
+
const config: Config = topLevelDefaults() as Config;
|
|
121
105
|
diffDisplayConfig = { ...DEFAULT_TOOL_DISPLAY_CONFIG };
|
|
122
106
|
try {
|
|
123
107
|
const data = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
@@ -147,17 +131,10 @@ function loadConfig(): Config {
|
|
|
147
131
|
diffDisplayConfig = { ...DEFAULT_TOOL_DISPLAY_CONFIG, ...data.diff };
|
|
148
132
|
}
|
|
149
133
|
} catch {}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const subagent = (process.env.PI_LEAN_TIDY_SUBAGENT || "").trim();
|
|
155
|
-
if (subagent === "claude" || subagent === "native") config.subagentRendering = subagent;
|
|
156
|
-
const live = (process.env.PI_LEAN_TIDY_LIVE_THINKING || "").trim().toLowerCase();
|
|
157
|
-
if (live === "on" || live === "off") config.liveThinking = live === "on";
|
|
158
|
-
if ((process.env.PI_TIDY_STATIC_THINKING || "").trim() === "1") config.liveThinking = false;
|
|
159
|
-
const tail = (process.env.PI_LEAN_TIDY_THINKING_TAIL || "").trim();
|
|
160
|
-
if (/^\d+$/.test(tail)) config.thinkingTailLines = Number(tail);
|
|
134
|
+
// 配置收敛为单一来源:lean-tidy.json 是唯一配置入口,配置型环境变量
|
|
135
|
+
// (PI_LEAN_TIDY_PROFILE/_SKILL/_SUBAGENT/_LIVE_THINKING、PI_TIDY_STATIC_THINKING、
|
|
136
|
+
// PI_LEAN_TIDY_THINKING_TAIL)已全部移除,设了也不生效(一南 2026-10-06);
|
|
137
|
+
// PI_LEAN_TIDY_DEBUG 与 PI_PATCH_VERSION_WARN 是诊断开关,不属于配置,仍然保留。
|
|
161
138
|
return config;
|
|
162
139
|
}
|
|
163
140
|
|
|
@@ -448,6 +425,8 @@ export default async function leanTidy(pi: ExtensionAPI): Promise<void> {
|
|
|
448
425
|
const impl = {
|
|
449
426
|
loadId: loadCounter,
|
|
450
427
|
config,
|
|
428
|
+
/** diff 显示配置的运行时引用(热键 apply 时被整体替换,getter 保证读到最新值)。 */
|
|
429
|
+
get diffDisplayConfig() { return diffDisplayConfig; },
|
|
451
430
|
hookMode,
|
|
452
431
|
containerRender: (container: any, width: number, chat: boolean): string[] =>
|
|
453
432
|
claude && chat ? claude.renderChat(container, width) : Container.prototype.render.call(container, width),
|
|
@@ -585,12 +564,78 @@ export default async function leanTidy(pi: ExtensionAPI): Promise<void> {
|
|
|
585
564
|
return true;
|
|
586
565
|
} catch { return false; }
|
|
587
566
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
567
|
+
// /tidy-redraw 命令已从命令面移除(内部工具不再占用命令列表);
|
|
568
|
+
// 整屏重绘能力由下方 F5 快捷键保留。
|
|
569
|
+
|
|
570
|
+
/** 热键运行时通道:原地改 config(闭包共享引用直达渲染层)并同步 SYMBOLS / diffDisplayConfig。
|
|
571
|
+
* 冷键在加载期固化(视图与面板注册),运行时 apply 无意义,直接跳过。 */
|
|
572
|
+
function applySetting(id: string, value: unknown): void {
|
|
573
|
+
const def = SETTING_BY_ID.get(id);
|
|
574
|
+
if (!def || def.cold) return;
|
|
575
|
+
if (id.startsWith("diff.")) {
|
|
576
|
+
diffDisplayConfig = { ...diffDisplayConfig, [id.slice("diff.".length)]: value };
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
(config as Record<string, unknown>)[id] = value;
|
|
580
|
+
if (id === "resultPrefix") SYMBOLS.resultPrefix = value as string;
|
|
581
|
+
if (id === "resultPrefixGap") SYMBOLS.resultPrefixGap = value as number;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** /skin-setting default 全链路:确认 → 文件重置(保留未知键)→ 热键运行时重置 → 整屏重绘 → 冷键提醒 /reload。 */
|
|
585
|
+
async function resetDefaultFlow(ctx: any): Promise<void> {
|
|
586
|
+
let confirmed = false;
|
|
587
|
+
try {
|
|
588
|
+
confirmed = await ctx?.ui?.confirm?.(
|
|
589
|
+
"恢复默认设置",
|
|
590
|
+
"将删除 lean-tidy.json 中的全部已知皮肤配置键(未知键保留),热键立即恢复默认。确定继续?",
|
|
591
|
+
);
|
|
592
|
+
} catch { confirmed = false; }
|
|
593
|
+
if (!confirmed) return; // Esc / 取消:文件与运行时零改动。
|
|
594
|
+
const { coldReset } = resetConfigFile(CONFIG_PATH);
|
|
595
|
+
// diff 显示配置先整体回出厂拷贝(文件 diff 里的未知键不留在运行时),
|
|
596
|
+
// 再走统一的热键 apply 通道逐键重置(幂等,值相同)。
|
|
597
|
+
diffDisplayConfig = { ...DEFAULT_TOOL_DISPLAY_CONFIG };
|
|
598
|
+
applyRuntimeDefaults(applySetting);
|
|
599
|
+
forceRedraw();
|
|
600
|
+
if (coldReset.length > 0) ctx?.ui?.notify?.(`已恢复默认,${coldReset.length} 项需 /reload 生效`, "info");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** 设置面板(工单 03):当前值读取统一走 currentValueOf——顶层键读共享 config,diff.* 读 diffDisplayConfig(闭包每次读模块变量,apply 整体替换后仍取最新值)。 */
|
|
604
|
+
const skinSettingDeps = {
|
|
605
|
+
currentValueOf: (id: string) => id.startsWith("diff.")
|
|
606
|
+
? diffDisplayConfig[id.slice("diff.".length) as keyof ToolDisplayConfig]
|
|
607
|
+
: (config as Record<string, unknown>)[id],
|
|
608
|
+
applySetting,
|
|
609
|
+
configPath: CONFIG_PATH,
|
|
610
|
+
forceRedraw,
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
// /skin-setting:无参 = 打开设置面板(仅 TUI;守卫与非 TUI 提示在面板模块内),
|
|
614
|
+
// default = 确认后一键恢复默认。命令补全只提示 default。
|
|
615
|
+
pi.registerCommand("skin-setting", {
|
|
616
|
+
description: "lean-tidy 皮肤设置(default = 恢复默认)",
|
|
617
|
+
getArgumentCompletions(prefix: string) {
|
|
618
|
+
const items = [{ value: "default", label: "default", description: "恢复全部默认(未知键保留)" }];
|
|
619
|
+
return items.filter((item) => item.value.startsWith(prefix));
|
|
620
|
+
},
|
|
621
|
+
handler: async (args: string, ctx: any) => {
|
|
622
|
+
const arg = (args ?? "").trim();
|
|
623
|
+
if (arg === "default") {
|
|
624
|
+
// confirm 在有 UI 的模式(含 RPC)可用;无 UI 时拿回 false 即零改动,天然安全,不做模式限制。
|
|
625
|
+
await resetDefaultFlow(ctx);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (arg === "") {
|
|
629
|
+
await handleSkinSettingCommand(skinSettingDeps, ctx);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
ctx?.ui?.notify?.(
|
|
633
|
+
`未知参数「${arg}」。用法:/skin-setting 打开设置面板(仅 TUI 模式);/skin-setting default 恢复默认。配置文件:${CONFIG_PATH}`,
|
|
634
|
+
"info",
|
|
635
|
+
);
|
|
592
636
|
},
|
|
593
637
|
});
|
|
638
|
+
|
|
594
639
|
pi.registerShortcut("f5" as any, {
|
|
595
640
|
description: "lean-tidy: 强制整屏重绘",
|
|
596
641
|
handler: () => { forceRedraw(); },
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 皮肤设置纯逻辑模块(零 TUI 依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 职责:25 个配置键的元数据(规范 id / label / 中文描述 / 类型 / 合法值或范围 /
|
|
5
|
+
* 冷热分类 / 默认值)、编辑侧校验、编辑形态派生判定(循环域 / 输入子菜单——
|
|
6
|
+
* def.type 的装配侧分派单点收敛于此,validateSetting 的 switch 是校验唯一实现点)、
|
|
7
|
+
* lean-tidy.json 的 read-modify-write 原子写、恢复默认的文件语义,以及热键默认值的
|
|
8
|
+
* 运行时 apply 通道(回调注入——本模块不 import 渲染层,避免与 index.ts 循环依赖)。
|
|
9
|
+
*
|
|
10
|
+
* 键全集:18 个顶层键(与 index.ts 的 Config 接口对应)+ 7 个 "diff.*" 点路径键
|
|
11
|
+
* (与 vendor/diff-config.ts 的 DEFAULT_TOOL_DISPLAY_CONFIG 同源)。
|
|
12
|
+
* 冷键(5 个)在加载期固化(视图与面板注册),改动只落文件、/reload 后生效;
|
|
13
|
+
* 其余为热键,渲染期读取,运行时原地变更即生效。
|
|
14
|
+
*
|
|
15
|
+
* 校验语义与 loadConfig 的读取语义刻意不同:这里(编辑/写入侧)严格拒绝非法值;
|
|
16
|
+
* loadConfig(读取侧)宽容——文件里的非法值被忽略、回落默认。写严读宽,文件
|
|
17
|
+
* 里只会有干净值。
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { basename, dirname, join } from "node:path";
|
|
21
|
+
import { DEFAULT_TOOL_DISPLAY_CONFIG } from "./vendor/diff-config.ts";
|
|
22
|
+
|
|
23
|
+
export type SettingType = "enum" | "boolean" | "number" | "string" | "readonly-array";
|
|
24
|
+
|
|
25
|
+
export interface SettingDef {
|
|
26
|
+
/** 规范 id:顶层键用 camelCase,diff 段用 "diff.xxx" 点路径(与 lean-tidy.json 键名一致)。 */
|
|
27
|
+
id: string;
|
|
28
|
+
/** 面板展示用的简短标签。 */
|
|
29
|
+
label: string;
|
|
30
|
+
/** 中文描述(含默认值),面板行与文档共用。 */
|
|
31
|
+
description: string;
|
|
32
|
+
type: SettingType;
|
|
33
|
+
defaultValue: unknown;
|
|
34
|
+
/** enum 类型的合法值集。 */
|
|
35
|
+
values?: readonly unknown[];
|
|
36
|
+
/** number 类型的范围(min 必填)。 */
|
|
37
|
+
min?: number;
|
|
38
|
+
max?: number;
|
|
39
|
+
/** true = 严格整数(0–100 / 0–4 类);false = 非负且向下取整(≥0 类)。 */
|
|
40
|
+
integer?: boolean;
|
|
41
|
+
/** 冷键:加载期固化,改动只落文件,需 /reload 生效。 */
|
|
42
|
+
cold: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 25 键元数据。默认值是唯一事实来源:loadConfig 经 topLevelDefaults() 消费,消除双源。 */
|
|
46
|
+
export const SETTING_DEFS: readonly SettingDef[] = [
|
|
47
|
+
// ---- 顶层 18 键 ----
|
|
48
|
+
{
|
|
49
|
+
id: "profile", label: "界面配置档案", type: "enum", values: ["claude", "native"], defaultValue: "claude", cold: true,
|
|
50
|
+
description: "整体呈现:claude = Claude Code 风格视图(默认);native = pi 原生回退。",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "skillRendering", label: "Skill 渲染", type: "enum", values: ["native", "claude"], defaultValue: "native", cold: false,
|
|
54
|
+
description: "Skill 调用块样式:native = pi 原生 [skill] 折叠行(默认);claude = ● Skill(name) 两行风格。",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "subagentRendering", label: "Subagent 渲染", type: "enum", values: ["native", "claude"], defaultValue: "claude", cold: true,
|
|
58
|
+
description: "Subagent 完成通知样式:claude = ● Subagent(desc) + 分支(默认);native = 保留包内面板。",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "detailsBackground", label: "详情背景色", type: "enum",
|
|
62
|
+
values: ["selectedBg", "customMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg", ""],
|
|
63
|
+
defaultValue: "selectedBg", cold: false,
|
|
64
|
+
description: "工具详情展开区的背景色(默认 selectedBg;空字符串 = 无背景)。",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "bashPreviewLines", label: "Bash 预览行数", type: "number", min: 0, max: 100, integer: true, defaultValue: 0, cold: false,
|
|
68
|
+
description: "bash 工具折叠态的输出预览行数,0–100(默认 0)。",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "codePreviewLines", label: "代码工具预览行数", type: "number", min: 0, max: 100, integer: true, defaultValue: 0, cold: false,
|
|
72
|
+
description: "read / write / edit / find 等折叠态预览行数,0–100(默认 0)。",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "errorPreviewLines", label: "错误预览行数", type: "number", min: 0, max: 100, integer: true, defaultValue: 0, cold: false,
|
|
76
|
+
description: "失败工具结果折叠态的预览行数,0–100(默认 0)。",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "liveThinking", label: "实时思考流", type: "boolean", defaultValue: true, cold: false,
|
|
80
|
+
description: "是否流式显示 thinking 块(默认 true)。",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "thinkingTailLines", label: "思考尾部行数", type: "number", min: 0, defaultValue: 5, cold: false,
|
|
84
|
+
description: "折叠 thinking 时尾部保留的行数,≥0(默认 5)。",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "foldAnimMs", label: "折叠动画时长", type: "number", min: 0, defaultValue: 240, cold: false,
|
|
88
|
+
description: "代码块折叠动画时长,毫秒,≥0(默认 240)。",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "stickyIncludeCustom", label: "sticky 含扩展消息", type: "boolean", defaultValue: false, cold: true,
|
|
92
|
+
description: "sticky 吸附池是否包含扩展注入的 CustomMessage(默认 false)。",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "skillResumePreview", label: "resume 预览折回", type: "boolean", defaultValue: true, cold: true,
|
|
96
|
+
description: "/resume 会话列表预览是否折回 [skill] 名(默认 true)。",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "resultPrefix", label: "结果行前缀", type: "string", defaultValue: "└", cold: false,
|
|
100
|
+
description: "工具结果行的拐角前缀符号(默认 └;可换 ↳ 或 ⎿)。",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "resultPrefixGap", label: "前缀后空格", type: "number", min: 0, max: 4, integer: true, defaultValue: 1, cold: false,
|
|
104
|
+
description: "前缀符号与内容之间的空格数,0–4(默认 1)。",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "normalizeForeignRenderers", label: "第三方输出规范化", type: "boolean", defaultValue: true, cold: false,
|
|
108
|
+
description: "第三方扩展渲染输出的行首 ⎿ 是否统一为 resultPrefix(默认 true)。",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "workflowRendering", label: "Workflow 渲染", type: "enum", values: ["claude", "native"], defaultValue: "claude", cold: true,
|
|
112
|
+
description: "workflow 完成卡片样式:claude = ● Workflow(name)(默认);native = 保留包内卡片。",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "foreignCardStyle", label: "未适配卡片样式", type: "enum", values: ["generic", "symbols"], defaultValue: "generic", cold: false,
|
|
116
|
+
description: "未适配 customType 的卡片:generic = 自动接管为通用卡片(默认);symbols = 仅统一符号不接管布局。",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: "foreignCardSkip", label: "卡片接管黑名单", type: "readonly-array", defaultValue: [], cold: false,
|
|
120
|
+
description: "不接管的 customType 黑名单(只读;默认 [])。",
|
|
121
|
+
},
|
|
122
|
+
// ---- diff.* 7 键(默认值直接引用 vendored DEFAULT_TOOL_DISPLAY_CONFIG,保持同源)----
|
|
123
|
+
{
|
|
124
|
+
id: "diff.diffViewMode", label: "diff 视图模式", type: "enum", values: ["auto", "split", "unified"],
|
|
125
|
+
defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.diffViewMode, cold: false,
|
|
126
|
+
description: "diff 展示模式:auto / split / unified(默认 auto)。",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: "diff.diffIndicatorMode", label: "diff 指示器", type: "enum", values: ["bars", "classic", "none"],
|
|
130
|
+
defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode, cold: false,
|
|
131
|
+
description: "diff 变更指示器:bars / classic / none(默认 bars)。",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "diff.diffSplitMinWidth", label: "split 最小宽度", type: "number", min: 0,
|
|
135
|
+
defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.diffSplitMinWidth, cold: false,
|
|
136
|
+
description: "split 视图的最小总宽度,低于则回落 unified(默认 120)。",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "diff.editDiffCollapsedLines", label: "edit 折叠行数", type: "number", min: 0,
|
|
140
|
+
defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.editDiffCollapsedLines, cold: false,
|
|
141
|
+
description: "edit diff 折叠时显示的上下文行数(默认 0)。",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "diff.writeDiffCollapsedLines", label: "write 折叠行数", type: "number", min: 0,
|
|
145
|
+
defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.writeDiffCollapsedLines, cold: false,
|
|
146
|
+
description: "write diff 折叠时显示的上下文行数(默认 0)。",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "diff.diffWordWrap", label: "diff 自动换行", type: "boolean",
|
|
150
|
+
defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.diffWordWrap, cold: false,
|
|
151
|
+
description: "diff 行超宽时是否自动换行(默认 true)。",
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: "diff.expandedPreviewMaxLines", label: "展开预览行上限", type: "number", min: 0,
|
|
155
|
+
defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines, cold: false,
|
|
156
|
+
description: "diff 展开态预览的最大行数(默认 40)。",
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
export const SETTING_BY_ID: ReadonlyMap<string, SettingDef> = new Map(SETTING_DEFS.map((def) => [def.id, def]));
|
|
161
|
+
|
|
162
|
+
/** 冷键 id 清单(派生自元数据):加载期固化,运行时不可 apply。 */
|
|
163
|
+
export const COLD_SETTING_IDS: readonly string[] = SETTING_DEFS.filter((def) => def.cold).map((def) => def.id);
|
|
164
|
+
|
|
165
|
+
/** 18 个顶层键的默认值对象——loadConfig 的初始值由此构造(默认值单一事实来源)。 */
|
|
166
|
+
export function topLevelDefaults(): Record<string, unknown> {
|
|
167
|
+
const defaults: Record<string, unknown> = {};
|
|
168
|
+
for (const def of SETTING_DEFS) {
|
|
169
|
+
if (!def.id.includes(".")) defaults[def.id] = def.defaultValue;
|
|
170
|
+
}
|
|
171
|
+
return defaults;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ---------- 值词汇与编辑形态派生(装配侧共用) ---------- */
|
|
175
|
+
|
|
176
|
+
/** 值显示词汇:面板与文件一致,统一用 JSON 字面量(true / 240 / "└" / ["a","b"])。 */
|
|
177
|
+
export function toJsonLiteral(value: unknown): string {
|
|
178
|
+
return JSON.stringify(value) ?? "null";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 该键的回车循环域:enum 给合法值集、boolean 给 true/false(JSON 字面量词汇);无循环编辑的键返回 undefined。 */
|
|
182
|
+
export function cycleValuesOf(def: SettingDef): string[] | undefined {
|
|
183
|
+
if (def.type === "enum") return (def.values ?? []).map((value) => toJsonLiteral(value));
|
|
184
|
+
if (def.type === "boolean") return ["true", "false"];
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 该键是否提供输入子菜单编辑:number / string 走子菜单输入框;enum/boolean 走列表循环,readonly-array 只读。 */
|
|
189
|
+
export function hasInputEditor(def: SettingDef): boolean {
|
|
190
|
+
return def.type === "number" || def.type === "string";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ---------- 校验(编辑侧,严格) ---------- */
|
|
194
|
+
|
|
195
|
+
export type ValidateResult = { ok: true; value: unknown } | { ok: false; error: string };
|
|
196
|
+
|
|
197
|
+
/** 校验单键取值:非法值拒绝并给出中文错误;通过时返回归一化后的值(≥0 键取整)。 */
|
|
198
|
+
export function validateSetting(id: string, value: unknown): ValidateResult {
|
|
199
|
+
const def = SETTING_BY_ID.get(id);
|
|
200
|
+
if (!def) return { ok: false, error: `未知配置键:${id}` };
|
|
201
|
+
switch (def.type) {
|
|
202
|
+
case "enum": {
|
|
203
|
+
if (def.values?.includes(value)) return { ok: true, value };
|
|
204
|
+
const legal = def.values?.map((v) => JSON.stringify(v)).join(" / ") ?? "";
|
|
205
|
+
return { ok: false, error: `${def.label} 只接受 ${legal} 之一` };
|
|
206
|
+
}
|
|
207
|
+
case "boolean":
|
|
208
|
+
return typeof value === "boolean"
|
|
209
|
+
? { ok: true, value }
|
|
210
|
+
: { ok: false, error: `${def.label} 只接受 true / false` };
|
|
211
|
+
case "number": {
|
|
212
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
213
|
+
return { ok: false, error: `${def.label} 需要是数字` };
|
|
214
|
+
}
|
|
215
|
+
if (def.integer && !Number.isInteger(value)) {
|
|
216
|
+
return { ok: false, error: `${def.label} 需要是整数(${def.min}–${def.max})` };
|
|
217
|
+
}
|
|
218
|
+
if (value < (def.min ?? -Infinity) || value > (def.max ?? Infinity)) {
|
|
219
|
+
const upper = def.max === undefined ? "∞" : def.max;
|
|
220
|
+
return { ok: false, error: `${def.label} 超出范围 ${def.min}–${upper}` };
|
|
221
|
+
}
|
|
222
|
+
return { ok: true, value: def.integer ? value : Math.floor(value) };
|
|
223
|
+
}
|
|
224
|
+
case "string":
|
|
225
|
+
return typeof value === "string" && value.trim()
|
|
226
|
+
? { ok: true, value }
|
|
227
|
+
: { ok: false, error: `${def.label} 不能为空白字符串` };
|
|
228
|
+
case "readonly-array":
|
|
229
|
+
return { ok: false, error: `${def.label} 为只读黑名单,不支持在此编辑(请手改配置文件)` };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* ---------- lean-tidy.json 读写 ---------- */
|
|
234
|
+
|
|
235
|
+
/** 读现有配置对象:文件不存在或 JSON 损坏按 {} 处理(写回时覆盖损坏内容)。 */
|
|
236
|
+
export function readConfigObject(path: string): Record<string, unknown> {
|
|
237
|
+
if (!existsSync(path)) return {};
|
|
238
|
+
try {
|
|
239
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
240
|
+
if (data && typeof data === "object" && !Array.isArray(data)) return data;
|
|
241
|
+
return {};
|
|
242
|
+
} catch {
|
|
243
|
+
return {};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 序列化格式:2 空格缩进 + 末尾换行(与手写习惯一致、diff 友好)。 */
|
|
248
|
+
export function serializeConfig(obj: Record<string, unknown>): string {
|
|
249
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 同目录临时文件 + rename 原子替换:中断不留半个文件。 */
|
|
253
|
+
export function writeConfigAtomic(path: string, obj: Record<string, unknown>): void {
|
|
254
|
+
const tmp = join(dirname(path), `${basename(path)}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`);
|
|
255
|
+
writeFileSync(tmp, serializeConfig(obj));
|
|
256
|
+
renameSync(tmp, path);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 在嵌套对象上按点路径取/建容器("diff.xxx" → obj.diff)。 */
|
|
260
|
+
function nestedContainer(obj: Record<string, unknown>, prefix: string): Record<string, unknown> {
|
|
261
|
+
const existing = obj[prefix];
|
|
262
|
+
if (existing && typeof existing === "object" && !Array.isArray(existing)) return existing;
|
|
263
|
+
const created: Record<string, unknown> = {};
|
|
264
|
+
obj[prefix] = created;
|
|
265
|
+
return created;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 单键 RMW:校验 → 读现有文件 → 只改目标键路径(含 diff.* 嵌套)→ 保留未知键 → 原子写回。 */
|
|
269
|
+
export function setSettingInFile(path: string, id: string, value: unknown): Record<string, unknown> {
|
|
270
|
+
const check = validateSetting(id, value);
|
|
271
|
+
if (!check.ok) throw new Error(check.error);
|
|
272
|
+
const obj = readConfigObject(path);
|
|
273
|
+
const dot = id.indexOf(".");
|
|
274
|
+
if (dot > 0) {
|
|
275
|
+
nestedContainer(obj, id.slice(0, dot))[id.slice(dot + 1)] = check.value;
|
|
276
|
+
} else {
|
|
277
|
+
obj[id] = check.value;
|
|
278
|
+
}
|
|
279
|
+
writeConfigAtomic(path, obj);
|
|
280
|
+
return obj;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/* ---------- 恢复默认的文件语义 ---------- */
|
|
284
|
+
|
|
285
|
+
export interface ResetFileResult {
|
|
286
|
+
/** 文件中原本非默认、被本次删除的冷键 id(调用方据此提醒「N 项需 /reload 生效」)。 */
|
|
287
|
+
coldReset: string[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 恢复默认:从文件删除全部已知键(顶层 18 + diff 对象内 7 个),未知键保留;
|
|
292
|
+
* diff 清空后整个移除。文件不存在则不创建(无事可删)。
|
|
293
|
+
* 冷键默认值均为原始类型(enum/boolean),用严格相等判断「原本非默认」。
|
|
294
|
+
*/
|
|
295
|
+
export function resetConfigFile(path: string): ResetFileResult {
|
|
296
|
+
if (!existsSync(path)) return { coldReset: [] };
|
|
297
|
+
const obj = readConfigObject(path);
|
|
298
|
+
const coldReset: string[] = [];
|
|
299
|
+
for (const def of SETTING_DEFS) {
|
|
300
|
+
if (def.id.includes(".")) continue;
|
|
301
|
+
if (!(def.id in obj)) continue;
|
|
302
|
+
if (def.cold && obj[def.id] !== def.defaultValue) coldReset.push(def.id);
|
|
303
|
+
delete obj[def.id];
|
|
304
|
+
}
|
|
305
|
+
const diff = obj.diff;
|
|
306
|
+
if (diff && typeof diff === "object" && !Array.isArray(diff)) {
|
|
307
|
+
for (const def of SETTING_DEFS) {
|
|
308
|
+
if (!def.id.startsWith("diff.")) continue;
|
|
309
|
+
delete (diff as Record<string, unknown>)[def.id.slice("diff.".length)];
|
|
310
|
+
}
|
|
311
|
+
if (Object.keys(diff).length === 0) delete obj.diff;
|
|
312
|
+
}
|
|
313
|
+
writeConfigAtomic(path, obj);
|
|
314
|
+
return { coldReset };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* ---------- 运行时重置(回调注入) ---------- */
|
|
318
|
+
|
|
319
|
+
/** 运行时 apply 回调:由 index.ts 注入(改 config 对象 / SYMBOLS / diffDisplayConfig)。 */
|
|
320
|
+
export type ApplyFn = (id: string, value: unknown) => void;
|
|
321
|
+
|
|
322
|
+
/** 热键逐键 apply 默认值;冷键加载期固化、不 apply(/reload 后生效)。 */
|
|
323
|
+
export function applyRuntimeDefaults(apply: ApplyFn): void {
|
|
324
|
+
for (const def of SETTING_DEFS) {
|
|
325
|
+
if (!def.cold) apply(def.id, def.defaultValue);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /skin-setting 设置面板模块(工单 03)。
|
|
3
|
+
*
|
|
4
|
+
* 职责:25 键面板行装配(SettingsList 的 SettingItem[]——冷键常驻 ⟳ 标注、
|
|
5
|
+
* 值用 JSON 字面量、enum/boolean 提供回车循环域)与 TUI overlay 面板的
|
|
6
|
+
* 挂载/编辑链路。编辑链路(校验 → 写文件 → 运行时原地生效 → 面板行刷新)
|
|
7
|
+
* 的纯逻辑都在 settings.ts,本模块只做装配;当前值读取与热键 apply 通道
|
|
8
|
+
* 由 index.ts 注入(避免本模块 import 渲染层,与 settings.ts 同一约束)。
|
|
9
|
+
*
|
|
10
|
+
* 数字 / 字符串键的回车输入编辑(子菜单输入框 + 原地错误提示)在工单 04 加入;
|
|
11
|
+
* readonly-array 键(foreignCardSkip)保持只读展示,不提供 submenu。
|
|
12
|
+
*/
|
|
13
|
+
import { Container, Input, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
14
|
+
import type { Component, SettingItem } from "@earendil-works/pi-tui";
|
|
15
|
+
import {
|
|
16
|
+
cycleValuesOf,
|
|
17
|
+
hasInputEditor,
|
|
18
|
+
SETTING_BY_ID,
|
|
19
|
+
SETTING_DEFS,
|
|
20
|
+
setSettingInFile,
|
|
21
|
+
toJsonLiteral,
|
|
22
|
+
validateSetting,
|
|
23
|
+
} from "./settings.ts";
|
|
24
|
+
import type { SettingDef } from "./settings.ts";
|
|
25
|
+
|
|
26
|
+
/** 面板宽度(列):60–80 区间取 76——description 换行少、窄终端仍居中可容。 */
|
|
27
|
+
const PANEL_WIDTH = 76;
|
|
28
|
+
/** 可视行数:SettingsList maxVisible ≈ 12。 */
|
|
29
|
+
const MAX_VISIBLE = 12;
|
|
30
|
+
|
|
31
|
+
export type NotifyLevel = "info" | "warning" | "error";
|
|
32
|
+
type NotifyFn = (message: string, level: NotifyLevel) => void;
|
|
33
|
+
|
|
34
|
+
export interface SkinSettingPanelDeps {
|
|
35
|
+
/** 当前值读取通道(index.ts 注入:顶层键 → 共享 config,diff.* → diffDisplayConfig)。 */
|
|
36
|
+
currentValueOf(id: string): unknown;
|
|
37
|
+
/** 热键原地生效通道(index.ts 注入;冷键在注入方 no-op——write-only)。 */
|
|
38
|
+
applySetting(id: string, value: unknown): void;
|
|
39
|
+
/** lean-tidy.json 路径(即改即存 RMW 的写入目标)。 */
|
|
40
|
+
configPath: string;
|
|
41
|
+
/** 整屏重绘(面板关闭时调一次,热键改动的效果即刻可见)。 */
|
|
42
|
+
forceRedraw(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 值显示词汇:实现已据至 settings.ts(与 cycleValuesOf 同源),此处 re-export 维持原导出面。
|
|
47
|
+
*/
|
|
48
|
+
export { toJsonLiteral };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 主题 fg 单点装配:theme.fg 存在则透传着色,否则原样返回文本。
|
|
52
|
+
* 列表主题 / 边框 / 输入子菜单三处共用,消除同形状闭包的多处重复。
|
|
53
|
+
*/
|
|
54
|
+
function themeFg(theme: any): (color: string, text: string) => string {
|
|
55
|
+
return (color, text) => (typeof theme?.fg === "function" ? theme.fg(color, text) : text);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ---------- 子菜单输入编辑(工单 04:number / string 键) ---------- */
|
|
59
|
+
|
|
60
|
+
/** SettingsList 的子菜单 done 契约(settings-list.d.ts):带值提交 → 行刷新 + onChange;无值 → 仅关闭回原行。 */
|
|
61
|
+
type SubmenuDone = (selectedValue?: string, options?: { navigateTo?: string }) => void;
|
|
62
|
+
|
|
63
|
+
/** 即改即存提交链路结果:失败时携带中文错误(由调用方决定 notify 还是子菜单原地展示)。 */
|
|
64
|
+
export type CommitResult = { ok: true; value: unknown } | { ok: false; error: string };
|
|
65
|
+
export type CommitFn = (id: string, value: unknown) => CommitResult;
|
|
66
|
+
|
|
67
|
+
/** 输入约束说明(提示行):数字键给范围与取整语义,字符串键给非空约束。 */
|
|
68
|
+
function inputConstraintHint(def: SettingDef): string {
|
|
69
|
+
if (def.type === "number") {
|
|
70
|
+
const upper = def.max === undefined ? "∞" : String(def.max);
|
|
71
|
+
return def.integer ? `范围 ${def.min}–${upper}(整数)` : `范围 ≥ ${def.min}(小数自动向下取整)`;
|
|
72
|
+
}
|
|
73
|
+
return "trim 后非空";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 文本 → 待校验值:数字键 Number() 解析(空输入按 NaN,交给 validateSetting 出中文错误);字符串键原样。 */
|
|
77
|
+
function parseInputValue(def: SettingDef, text: string): unknown {
|
|
78
|
+
if (def.type !== "number") return text;
|
|
79
|
+
const trimmed = text.trim();
|
|
80
|
+
return trimmed === "" ? Number.NaN : Number(trimmed);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 原地错误行:无错误时零行,有错误时一行红色中文提示(非法输入不落盘的呈现位)。 */
|
|
84
|
+
class SubmenuErrorLine implements Component {
|
|
85
|
+
error: string | null = null;
|
|
86
|
+
private readonly fg: (color: string, text: string) => string;
|
|
87
|
+
constructor(fg: (color: string, text: string) => string) {
|
|
88
|
+
this.fg = fg;
|
|
89
|
+
}
|
|
90
|
+
invalidate(): void {}
|
|
91
|
+
render(_width: number): string[] {
|
|
92
|
+
return this.error === null ? [] : [this.fg("error", ` ✗ ${this.error}`)];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 数字 / 字符串键的输入编辑子菜单:标题行(键标签)+ 约束行(范围 / 非空 + 当前值)
|
|
98
|
+
* + pi-tui Input(placeholder = 当前值)+ 原地错误行 + 操作提示。
|
|
99
|
+
* 提交流:解析 → 即改即存链路(校验 → 落盘 → 热键原地生效 → 面板行刷新)→
|
|
100
|
+
* done(新 JSON 字面量) 关子菜单;非法:原地错误行、输入保留、不落盘不关。
|
|
101
|
+
* Esc:done() 无值关闭,回到列表原行。
|
|
102
|
+
*
|
|
103
|
+
* 注意:done 用无 options 的 done(字面量),不用 { navigateTo: id }——pi-tui 的
|
|
104
|
+
* closeSubmenu 对 navigateTo 会 selectItem(id) 后 activateItem() 自动重开目标键的
|
|
105
|
+
* 子菜单,与「提交后关子菜单回原行」矛盾;无 options 的 done 已由 SettingsList
|
|
106
|
+
* 完成 行刷新(item.currentValue = 值)+ 回原行(submenuItemIndex 恢复)。
|
|
107
|
+
* 经真实 SettingsList 提交时,本组件先 commit 一次,done → onChange 会再 commit
|
|
108
|
+
* 一次(幂等:同值重写 + 同值 apply,无副作用)。
|
|
109
|
+
*/
|
|
110
|
+
export class SettingInputSubmenu extends Container {
|
|
111
|
+
private readonly def: SettingDef;
|
|
112
|
+
private readonly input: Input;
|
|
113
|
+
private readonly errorLine: SubmenuErrorLine;
|
|
114
|
+
|
|
115
|
+
constructor(
|
|
116
|
+
def: SettingDef,
|
|
117
|
+
currentValueJson: string,
|
|
118
|
+
done: SubmenuDone,
|
|
119
|
+
commit: CommitFn,
|
|
120
|
+
fg: (color: string, text: string) => string,
|
|
121
|
+
) {
|
|
122
|
+
super();
|
|
123
|
+
this.def = def;
|
|
124
|
+
let current: unknown;
|
|
125
|
+
try {
|
|
126
|
+
current = JSON.parse(currentValueJson);
|
|
127
|
+
} catch {
|
|
128
|
+
current = def.defaultValue;
|
|
129
|
+
}
|
|
130
|
+
const placeholder = def.type === "number" ? String(current) : String(current ?? "");
|
|
131
|
+
this.addChild(new Text(fg("accent", def.label), 1, 0));
|
|
132
|
+
this.addChild(new Text(fg("muted", `${inputConstraintHint(def)} · 当前 ${currentValueJson}`), 1, 0));
|
|
133
|
+
this.addChild(new Spacer(1));
|
|
134
|
+
this.input = new Input({ placeholder, placeholderStyle: (text: string) => fg("dim", text) });
|
|
135
|
+
this.errorLine = new SubmenuErrorLine(fg);
|
|
136
|
+
this.input.onSubmit = (text: string) => {
|
|
137
|
+
const result = commit(def.id, parseInputValue(def, text));
|
|
138
|
+
if (!result.ok) {
|
|
139
|
+
// 原地提示不落盘:子菜单里显示中文错误行,输入框保留用户输入供改正,不关子菜单。
|
|
140
|
+
this.errorLine.error = result.error;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.errorLine.error = null;
|
|
144
|
+
done(toJsonLiteral(result.value));
|
|
145
|
+
};
|
|
146
|
+
this.input.onEscape = () => {
|
|
147
|
+
done(); // 无值关闭:不触发 onChange,回到列表原行。
|
|
148
|
+
};
|
|
149
|
+
this.addChild(this.input);
|
|
150
|
+
this.addChild(this.errorLine);
|
|
151
|
+
this.addChild(new Text(fg("dim", "Enter 保存 · Esc 取消"), 1, 0));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
handleInput(data: string): void {
|
|
155
|
+
// 新输入作废旧错误(提交时会重新校验给出新结果);按键全部交给输入框。
|
|
156
|
+
this.errorLine.error = null;
|
|
157
|
+
this.input.handleInput(data);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 面板行装配:25 键 → SettingItem[]。
|
|
163
|
+
* 冷键标注双保险:label 尾部 ⟳(SettingsList 的 description 只在选中行显示,
|
|
164
|
+
* 要「常驻可见」必须落在 label 上)+ description 尾部(⟳ 需 /reload 生效)。
|
|
165
|
+
*/
|
|
166
|
+
export function buildSettingItems(currentValueOf: (id: string) => unknown): SettingItem[] {
|
|
167
|
+
return SETTING_DEFS.map((def) => {
|
|
168
|
+
const item: SettingItem = {
|
|
169
|
+
id: def.id,
|
|
170
|
+
label: def.cold ? `${def.label} ⟳` : def.label,
|
|
171
|
+
description: def.cold ? `${def.description}(⟳ 需 /reload 生效)` : def.description,
|
|
172
|
+
currentValue: toJsonLiteral(currentValueOf(def.id)),
|
|
173
|
+
};
|
|
174
|
+
// 循环域(enum/boolean)由 settings.ts 的 cycleValuesOf 单点派生;number/string 的输入
|
|
175
|
+
// submenu 需要提交链路与主题,在 handleSkinSettingCommand 的装配处注入(见 buildPanel);
|
|
176
|
+
// 此处保持纯装配不接。readonly-array 保持只读。
|
|
177
|
+
const values = cycleValuesOf(def);
|
|
178
|
+
if (values) item.values = values;
|
|
179
|
+
return item;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 全宽字符(CJK / 全角)按 2 列计——边框行自适应渲染宽度用。 */
|
|
184
|
+
function displayWidth(text: string): number {
|
|
185
|
+
let width = 0;
|
|
186
|
+
for (const ch of text) {
|
|
187
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
188
|
+
const wide =
|
|
189
|
+
(code >= 0x1100 && code <= 0x115f) || // 谚文
|
|
190
|
+
(code >= 0x2e80 && code <= 0x303e) || (code >= 0x3041 && code <= 0x33ff) ||
|
|
191
|
+
(code >= 0x3400 && code <= 0x4dbf) || (code >= 0x4e00 && code <= 0x9fff) ||
|
|
192
|
+
(code >= 0xa960 && code <= 0xa97f) || (code >= 0xac00 && code <= 0xd7a3) ||
|
|
193
|
+
(code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) ||
|
|
194
|
+
(code >= 0xff00 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6);
|
|
195
|
+
width += wide ? 2 : 1;
|
|
196
|
+
}
|
|
197
|
+
return width;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 自绘边框行(pi-tui 的 Box 只有 padding+bg 无边框,上/下边框用 ─ 拼)。 */
|
|
201
|
+
class BorderLine implements Component {
|
|
202
|
+
private readonly makeLine: (width: number) => string;
|
|
203
|
+
constructor(makeLine: (width: number) => string) {
|
|
204
|
+
this.makeLine = makeLine;
|
|
205
|
+
}
|
|
206
|
+
invalidate(): void {}
|
|
207
|
+
render(width: number): string[] {
|
|
208
|
+
return [this.makeLine(Math.max(width, 8))];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 标题边框行:── 皮肤设置(…) ──,标题居中、随渲染宽度自适应。 */
|
|
213
|
+
function titleBorder(fg: (color: string, text: string) => string): BorderLine {
|
|
214
|
+
return new BorderLine((width) => {
|
|
215
|
+
const title = " 皮肤设置(回车即改即存 · 输入即搜索) ";
|
|
216
|
+
const fill = width - displayWidth(title) - 2;
|
|
217
|
+
const left = Math.max(1, Math.floor(fill / 2));
|
|
218
|
+
const right = Math.max(1, fill - left);
|
|
219
|
+
return fg("borderAccent", `─${"─".repeat(left)}${title}${"─".repeat(right)}─`);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 底部边框行:纯 ─ 收尾。 */
|
|
224
|
+
function bottomBorder(fg: (color: string, text: string) => string): BorderLine {
|
|
225
|
+
return new BorderLine((width) => fg("border", "─".repeat(width)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** SettingsList 配色:照 pi 自家 getSettingsListTheme 的口径(选中 accent、未选中 muted、描述 dim)。 */
|
|
229
|
+
function makeListTheme(theme: any) {
|
|
230
|
+
const fg = themeFg(theme);
|
|
231
|
+
return {
|
|
232
|
+
label: (text: string, selected: boolean) => (selected ? fg("accent", text) : text),
|
|
233
|
+
value: (text: string, selected: boolean) => (selected ? fg("accent", text) : fg("muted", text)),
|
|
234
|
+
description: (text: string) => fg("dim", text),
|
|
235
|
+
cursor: fg("accent", "→ "),
|
|
236
|
+
hint: (text: string) => fg("dim", text),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 面板组件:标题边框 + SettingsList(搜索 + 25 键 + 选中行描述)+ 底部边框。
|
|
242
|
+
* 键盘焦点由 overlay 的 setFocus 落在本容器上,handleInput 转发给列表
|
|
243
|
+
* (搜索输入框与列表按键都由 SettingsList 自己分发)。
|
|
244
|
+
*/
|
|
245
|
+
class SkinSettingPanelComponent extends Container {
|
|
246
|
+
readonly list: SettingsList;
|
|
247
|
+
constructor(theme: any, items: SettingItem[], onChange: (id: string, newValue: string) => void, onCancel: () => void) {
|
|
248
|
+
super();
|
|
249
|
+
const fg = themeFg(theme);
|
|
250
|
+
this.list = new SettingsList(items, MAX_VISIBLE, makeListTheme(theme), onChange, onCancel, { enableSearch: true });
|
|
251
|
+
this.addChild(titleBorder(fg));
|
|
252
|
+
this.addChild(this.list);
|
|
253
|
+
this.addChild(bottomBorder(fg));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
handleInput(data: string): void {
|
|
257
|
+
this.list.handleInput(data);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
invalidate(): void {
|
|
261
|
+
super.invalidate();
|
|
262
|
+
this.list.invalidate();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* /skin-setting 无参命令处理:TUI 开面板,非 TUI 提示「面板仅 TUI + 文件路径」(守卫自 index.ts 挪入)。
|
|
268
|
+
* 直接以 (deps, ctx) 函数导出,由 index.ts 接线——原先 createSkinSettingPanel 返回单方法
|
|
269
|
+
* 对象属 Middle Man;deps 参数本身即注入面,测试走 index.ts 注册的命令面,不受影响。
|
|
270
|
+
*/
|
|
271
|
+
export async function handleSkinSettingCommand(deps: SkinSettingPanelDeps, ctx: any): Promise<void> {
|
|
272
|
+
function buildPanel(theme: any, done: () => void, notify: NotifyFn): SkinSettingPanelComponent {
|
|
273
|
+
// 冷键 notify 去抖:同一键同一次面板会话只在首次改动时提醒。
|
|
274
|
+
// 理由:回车循环极易连按(切过头再切回),每按一次弹一条会刷屏遮挡面板;
|
|
275
|
+
// 首次提醒已传达「此键需 /reload」,同键后续改动无新信息。重开面板重置(新意图)。
|
|
276
|
+
const coldNotified = new Set<string>();
|
|
277
|
+
let panel: SkinSettingPanelComponent;
|
|
278
|
+
// 即改即存提交链路(enum 循环与子菜单输入共用):校验 → RMW 落盘 → 热键原地
|
|
279
|
+
// 生效 → 面板行刷新 → 冷键首次 notify。失败返回中文错误,由调用方决定呈现
|
|
280
|
+
// (列表循环路径 notify + 行回滚;子菜单路径原地错误行,不关子菜单)。
|
|
281
|
+
const commit: CommitFn = (id, value) => {
|
|
282
|
+
// 「未知配置键」错误文案由 validateSetting 单点产出(原先此处的预查是其重复);
|
|
283
|
+
// check.ok ⇒ SETTING_BY_ID 必含 id(冷键 notify 还需要 def 元数据,故再取一次)。
|
|
284
|
+
const check = validateSetting(id, value);
|
|
285
|
+
if (!check.ok) return { ok: false, error: check.error };
|
|
286
|
+
const def = SETTING_BY_ID.get(id)!;
|
|
287
|
+
try {
|
|
288
|
+
setSettingInFile(deps.configPath, id, check.value);
|
|
289
|
+
} catch (error: any) {
|
|
290
|
+
return { ok: false, error: `写入 ${deps.configPath} 失败:${error?.message ?? error}` };
|
|
291
|
+
}
|
|
292
|
+
deps.applySetting(id, check.value); // 热键原地生效;冷键 write-only(注入方 no-op)
|
|
293
|
+
panel.list.updateValue(id, toJsonLiteral(check.value));
|
|
294
|
+
if (def.cold && !coldNotified.has(id)) {
|
|
295
|
+
coldNotified.add(id);
|
|
296
|
+
notify(`⟳ ${def.label} 已写入,/reload 后生效`, "info");
|
|
297
|
+
}
|
|
298
|
+
return { ok: true, value: check.value };
|
|
299
|
+
};
|
|
300
|
+
const onChange = (id: string, newValueJson: string) => {
|
|
301
|
+
if (!SETTING_BY_ID.has(id)) return;
|
|
302
|
+
// SettingsList 循环时已先把行值改掉,回滚基准用运行时真值(apply 还没发生)。
|
|
303
|
+
const previousJson = toJsonLiteral(deps.currentValueOf(id));
|
|
304
|
+
let value: unknown;
|
|
305
|
+
try {
|
|
306
|
+
value = JSON.parse(newValueJson);
|
|
307
|
+
} catch {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const result = commit(id, value);
|
|
311
|
+
if (!result.ok) {
|
|
312
|
+
panel.list.updateValue(id, previousJson);
|
|
313
|
+
notify(result.error, "error");
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const onCancel = () => {
|
|
317
|
+
done();
|
|
318
|
+
deps.forceRedraw();
|
|
319
|
+
};
|
|
320
|
+
// number / string 键接上输入子菜单(在面板装配处接:需要 commit 链与主题;
|
|
321
|
+
// 裸 buildSettingItems 不接,保持纯装配)。diff.* 数字键与顶层数字键同一条路径,
|
|
322
|
+
// 类型判定由 settings.ts 的 hasInputEditor 单点提供。
|
|
323
|
+
const fg = themeFg(theme);
|
|
324
|
+
const items = buildSettingItems(deps.currentValueOf).map((item) => {
|
|
325
|
+
const def = SETTING_BY_ID.get(item.id);
|
|
326
|
+
if (def && hasInputEditor(def)) {
|
|
327
|
+
item.submenu = (currentValue: string, doneSubmenu: SubmenuDone) =>
|
|
328
|
+
new SettingInputSubmenu(def, currentValue, doneSubmenu, commit, fg);
|
|
329
|
+
}
|
|
330
|
+
return item;
|
|
331
|
+
});
|
|
332
|
+
panel = new SkinSettingPanelComponent(theme, items, onChange, onCancel);
|
|
333
|
+
return panel;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const notify: NotifyFn = (message, level) => {
|
|
337
|
+
ctx?.ui?.notify?.(message, level);
|
|
338
|
+
};
|
|
339
|
+
if (ctx?.mode !== "tui" || typeof ctx?.ui?.custom !== "function") {
|
|
340
|
+
notify(`用法:/skin-setting 打开设置面板(仅 TUI 模式);/skin-setting default 恢复默认。配置文件:${deps.configPath}`, "info");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
await ctx.ui.custom(
|
|
344
|
+
(_tui: any, theme: any, _keybindings: any, done: () => void) => buildPanel(theme, done, notify),
|
|
345
|
+
{ overlay: true, overlayOptions: { anchor: "center" as const, width: PANEL_WIDTH } },
|
|
346
|
+
);
|
|
347
|
+
}
|