@yinan_chen/pi-claude-code-ui 0.8.9 → 0.9.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 CHANGED
@@ -1,41 +1,59 @@
1
1
  # @yinan_chen/pi-claude-code-ui
2
2
 
3
+ English | [简体中文](README.zh-CN.md)
4
+
3
5
  ![preview](assets/preview.png)
4
6
 
5
- ### 演示
7
+ ### Demo
6
8
 
7
9
  ![demo](https://github.com/user-attachments/assets/d264935a-79fc-434e-be97-05c8210144fe)
8
10
 
9
- 动图预览(不渲染视频的平台,如 npm): ![gif](assets/demo.gif)
11
+ Animated GIF preview (for platforms that don't render video, such as npm): ![gif](assets/demo.gif)
10
12
 
11
- > 原名 lean-tidy —— Claude Code 风格的 Pi 界面增强扩展:Claude 式对话转录视图、富文本 diff 渲染、子代理面板与工作状态提示。
13
+ > Formerly lean-tidy — a Claude Code-style UI enhancement extension for Pi: Claude-style conversation transcript views, rich diff rendering, subagent panels, and working-state hints.
12
14
 
13
- ## 安装
15
+ ## Install
14
16
 
15
17
  ```bash
16
18
  pi install npm:@yinan_chen/pi-claude-code-ui
17
19
  ```
18
20
 
19
- 试运行(不写入配置):
21
+ Trial run (doesn't write to your config):
20
22
 
21
23
  ```bash
22
24
  pi -e npm:@yinan_chen/pi-claude-code-ui
23
25
  ```
24
26
 
25
- ## 功能
27
+ ## Features
28
+
29
+ - **Claude-style views** — conversation transcript adapter, with Pi native fallback
30
+ - **Sticky message bubble** — pins the user message currently being answered at the top while scrolling; click to jump back (built in since v0.2.0; formerly the sticky-first-message extension)
31
+ - **Skill recollapse** — expanded skill invocations automatically fold back to `/skill:name args`, covering four blind spots such as /tree and /fork backfill (built in since v0.2.0; formerly the skill-block-recollapse extension)
32
+ - **Full third-party renderer takeover** — cards from any extension (including future unmatched ones) render automatically in a unified `● card` style, covering tools / messages / entries at all three layers, zero config and zero adaptation; dispatch-layer interception is unaffected by load order (v0.7.0)
33
+ - **Rich diff rendering** — syntax-highlighted diffs for edit/write tools (vendored, MIT)
34
+ - **Subagent panel** — background agent status at a glance
35
+ - **Working-state hints** — working message / running effect
36
+ - **Polished read-only tools** — rendering for read / bash / grep / find / ls results
37
+ - **Skin Settings panel** — `/skin-setting` adjusts every skin setting with save-as-you-edit; `/skin-setting default` resets to defaults in one shot (v0.9.0)
38
+
39
+ ## Configuration
40
+
41
+ ### Recommended: the `/skin-setting` panel
42
+
43
+ Type `/skin-setting` in a pi session to open the centered overlay Skin Settings panel, where changes save as you make them — every edit is validated, written to the config file immediately, and reflected in the panel row, with no save/discard burden:
44
+
45
+ - Built-in search filtering to quickly locate any of the 25 config keys; each row shows its description and default value
46
+ - Enum / boolean keys cycle through legal values on Enter, so you can't pick an invalid one; number / string keys open an input box on Enter with range validation (e.g. 0–100, 0–4)
47
+ - Most keys are live settings — once you change them and close the panel, a full redraw makes them visible at once; reload-required settings are fixed at load time, carry a persistent `⟳ needs /reload` annotation, and get one extra reminder when changed
48
+ - The `foreignCardSkip` blocklist is shown read-only — its state stays visible without risk of accidental edits
49
+ - `/skin-setting default`: one-shot reset to defaults after a confirmation dialog (only this skin's config keys are removed; anything else you wrote in the file is kept)
50
+ - In non-TUI modes (RPC / JSON / print) it prints the config file path instead — use the file-based approach below
26
51
 
27
- - **Claude 式视图** — 对话转录适配器,Pi 原生回退
28
- - **吸附消息泡(sticky)** — 滚动时顶部吸附显示当前正在回答的用户消息原文,点击回位(v0.2.0 内置,原 sticky-first-message 扩展)
29
- - **Skill 折叠(recollapse)** — 展开的 skill 全文自动折回 `/skill:名 参数`,覆盖 /tree、/fork 回填等四处盲区(v0.2.0 内置,原 skill-block-recollapse 扩展)
30
- - **第三方渲染全接管** — 任何扩展的卡片(含未来未适配的)自动渲染为统一的 `● 卡片` 风格,工具/消息/条目三层全覆盖,零配置零适配;分发层拦截不受加载顺序影响(v0.7.0)
31
- - **富 diff 渲染** — edit/write 工具的语法高亮 diff(vendored, MIT)
32
- - **子代理面板** — 后台 agent 状态一目了然
33
- - **工作状态提示** — working message / running effect
34
- - **只读工具美化** — read/bash/grep/find/ls 结果渲染
52
+ **F5** forces a full-screen redraw at any time (the habitual fix for leftover artifacts).
35
53
 
36
- ## 配置
54
+ ### Editing the config file directly
37
55
 
38
- 配置文件:`~/.pi/agent/lean-tidy.json`(不存在时全部使用默认值):
56
+ You can also edit `~/.pi/agent/lean-tidy.json` directly (when the file doesn't exist, all defaults apply). Manual edits take effect after `/reload`; panel edits apply instantly for live settings, while reload-required settings (those marked `⟳` in the table below) need `/reload`:
39
57
 
40
58
  ```json
41
59
  {
@@ -69,36 +87,36 @@ pi -e npm:@yinan_chen/pi-claude-code-ui
69
87
  }
70
88
  ```
71
89
 
72
- | 键 | 默认 | 说明 |
90
+ | Key | Default | Description |
73
91
  |---|---|---|
74
- | `profile` | `"claude"` | 整体风格:`claude` / `native`(pi 原生) |
75
- | `skillRendering` | `"native"` | skill 块样式:pi 原生折叠行 / `claude` 两行风格 |
76
- | `subagentRendering` | `"claude"` | 子代理完成通知:`claude` 分支风格 / `native` 保留包内面板 |
77
- | `detailsBackground` | `"selectedBg"` | 详情背景色(主题色键名,可选 `customMessageBg`/`toolPendingBg`/`toolSuccessBg`/`toolErrorBg`/`""`) |
78
- | `bash/code/errorPreviewLines` | `0`/`0`/`0` | 三类工具结果的预览行数(0–100;0 = 默认折叠) |
79
- | `liveThinking` | `true` | 思考过程流式展示 |
80
- | `thinkingTailLines` | `5` | 思考尾行数 |
81
- | `foldAnimMs` | `240` | 折叠动画时长(ms,0 关闭) |
82
- | `stickyIncludeCustom` | `false` | 吸附泡是否包含扩展注入/跨会话转发的旁路消息 |
83
- | `skillResumePreview` | `true` | `/resume` 列表预览是否折回 `[skill] 名` 单行 |
84
- | `resultPrefix` | `"└"` | 工具结果行拐角前缀(制表符族,等宽字体最稳;可换 `↳` 或 Claude Code 官方的 `⎿`,后两者在部分字体下会变宽) |
85
- | `resultPrefixGap` | `1` | 前缀符号与内容之间的空格数(0–4) |
86
- | `normalizeForeignRenderers` | `true` | 第三方扩展卡片输出里行首 `⎿` 自动规范化为 `resultPrefix`(任何未适配扩展的渲染都能统一风格;关闭可看第三方原版) |
87
- | `workflowRendering` | `"claude"` | workflow 完成卡片样式:`● Workflow(name)` 风格 / `native` 保留包内卡片。分发层拦截实现,不受包加载顺序影响 |
88
- | `foreignCardStyle` | `"generic"` | 未适配扩展的卡片:自动接管为通用 `● 卡片`(零配置);`symbols` = 仅统一符号不接管布局 |
89
- | `foreignCardSkip` | `[]` | 不接管的 customType 黑名单(交互型第三方卡片异常时按类型跳过) |
90
- | `diff.diffViewMode` | `"auto"` | diff 视图:`auto`/`split`/`unified` |
91
- | `diff.diffIndicatorMode` | `"bars"` | 变更指示条:`bars`/`classic`/`none` |
92
- | `diff.diffSplitMinWidth` | `120` | 宽于此才用 split 视图 |
93
- | `diff.edit/writeDiffCollapsedLines` | `0`/`0` | edit/write 默认折叠行数(0 = 默认展开) |
94
- | `diff.diffWordWrap` | `true` | diff 自动换行 |
95
- | `diff.expandedPreviewMaxLines` | `40` | 展开后最多显示行数 |
96
-
97
- ## 兼容性
98
-
99
- 按 `VERIFIED_PI_VERSION = 1.0.2` 验收;版本不一致时扩展会在 session 启动时提示一次。
100
-
101
- ## 从源码安装
92
+ | `profile` ⟳ | `"claude"` | Overall look: claude = Claude Code-style views (default); native = pi native fallback. |
93
+ | `skillRendering` | `"native"` | Skill invocation block style: native = pi native [skill] collapsed line (default); claude = ● Skill(name) two-line style. |
94
+ | `subagentRendering` ⟳ | `"claude"` | Subagent completion notice style: claude = ● Subagent(desc) + branches (default); native = keep the bundled panel. |
95
+ | `detailsBackground` | `"selectedBg"` | Background color of the expanded tool details area; theme color key — `customMessageBg`/`toolPendingBg`/`toolSuccessBg`/`toolErrorBg` also work, `""` = none. |
96
+ | `bash/code/errorPreviewLines` | `0`/`0`/`0` | Preview lines for the three tool-result classes, 0–100 (0 = collapsed by default): bash output / read-write-edit-find tools / failed results. |
97
+ | `liveThinking` | `true` | Stream thinking blocks as they arrive. |
98
+ | `thinkingTailLines` | `5` | Lines kept at the tail when thinking is collapsed, ≥0. |
99
+ | `foldAnimMs` | `240` | Code block fold animation duration in milliseconds, ≥0 (0 disables it). |
100
+ | `stickyIncludeCustom` ⟳ | `false` | Whether the sticky bubble includes side-channel messages: CustomMessages injected by extensions or forwarded across sessions. |
101
+ | `skillResumePreview` ⟳ | `true` | Whether the /resume session list preview folds back to [skill] names. |
102
+ | `resultPrefix` | `"└"` | Corner prefix symbol for tool result lines (tab-drawing glyphs are the most reliable in monospace fonts; `↳` or Claude Code's official `⎿` also work, though both may widen in some fonts). |
103
+ | `resultPrefixGap` | `1` | Spaces between the prefix symbol and content, 0–4. |
104
+ | `normalizeForeignRenderers` | `true` | Rewrite the leading ⎿ of third-party extension output to resultPrefix (unifies the style of any unmatched extension's rendering; turn it off to see the third-party original). |
105
+ | `workflowRendering` ⟳ | `"claude"` | Workflow completion card style: claude = ● Workflow(name) (default); native = keep the bundled card. Dispatch-layer interception, unaffected by package load order. |
106
+ | `foreignCardStyle` | `"generic"` | Cards for unmatched customTypes: generic = take over as a unified `● card` (zero config); symbols = unify symbols only, keep layout. |
107
+ | `foreignCardSkip` | `[]` | customType blocklist never taken over (skip by type when an interactive third-party card misbehaves). |
108
+ | `diff.diffViewMode` | `"auto"` | Diff display mode: auto / split / unified. |
109
+ | `diff.diffIndicatorMode` | `"bars"` | Diff change indicator: bars / classic / none. |
110
+ | `diff.diffSplitMinWidth` | `120` | Minimum total width for split view; falls back to unified below it. |
111
+ | `diff.edit/writeDiffCollapsedLines` | `0`/`0` | Context lines kept when edit / write diffs are collapsed (0 = expanded by default). |
112
+ | `diff.diffWordWrap` | `true` | Wrap overly wide diff lines. |
113
+ | `diff.expandedPreviewMaxLines` | `40` | Maximum lines for expanded diff previews. |
114
+
115
+ ## Compatibility
116
+
117
+ Verified against `VERIFIED_PI_VERSION = 1.0.2`; on a version mismatch the extension shows a single notice at session startup.
118
+
119
+ ## Install from source
102
120
 
103
121
  ```bash
104
122
  pi install git:github.com/chenyn273/pi-claude-code-ui@v0.2.0
@@ -0,0 +1,123 @@
1
+ # @yinan_chen/pi-claude-code-ui
2
+
3
+ [English](README.md) | 简体中文
4
+
5
+ ![preview](assets/preview.png)
6
+
7
+ ### 演示
8
+
9
+ ![demo](https://github.com/user-attachments/assets/d264935a-79fc-434e-be97-05c8210144fe)
10
+
11
+ 动图预览(不渲染视频的平台,如 npm): ![gif](assets/demo.gif)
12
+
13
+ > 原名 lean-tidy —— Claude Code 风格的 Pi 界面增强扩展:Claude 式对话转录视图、富文本 diff 渲染、子代理面板与工作状态提示。
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ pi install npm:@yinan_chen/pi-claude-code-ui
19
+ ```
20
+
21
+ 试运行(不写入配置):
22
+
23
+ ```bash
24
+ pi -e npm:@yinan_chen/pi-claude-code-ui
25
+ ```
26
+
27
+ ## 功能
28
+
29
+ - **Claude 式视图** — 对话转录适配器,Pi 原生回退
30
+ - **吸附消息泡(sticky)** — 滚动时顶部吸附显示当前正在回答的用户消息原文,点击回位(v0.2.0 内置,原 sticky-first-message 扩展)
31
+ - **Skill 折叠(recollapse)** — 展开的 skill 全文自动折回 `/skill:名 参数`,覆盖 /tree、/fork 回填等四处盲区(v0.2.0 内置,原 skill-block-recollapse 扩展)
32
+ - **第三方渲染全接管** — 任何扩展的卡片(含未来未适配的)自动渲染为统一的 `● 卡片` 风格,工具/消息/条目三层全覆盖,零配置零适配;分发层拦截不受加载顺序影响(v0.7.0)
33
+ - **富 diff 渲染** — edit/write 工具的语法高亮 diff(vendored, MIT)
34
+ - **子代理面板** — 后台 agent 状态一目了然
35
+ - **工作状态提示** — working message / running effect
36
+ - **只读工具美化** — read/bash/grep/find/ls 结果渲染
37
+ - **设置面板** — `/skin-setting` 即改即存调整全部皮肤配置,`/skin-setting default` 一键恢复默认(v0.9.0)
38
+
39
+ ## 配置
40
+
41
+ ### 推荐方式:`/skin-setting` 设置面板
42
+
43
+ 在 pi 会话里输入 `/skin-setting` 打开居中 overlay 设置面板,即改即存——每次改动校验后立即写入配置文件并刷新面板行,没有"保存/放弃"负担:
44
+
45
+ - 内置搜索过滤,25 个配置键快速定位;每行附中文说明与默认值
46
+ - 枚举/布尔键回车在合法值间循环切换,不会选错;数字/字符串键回车进入输入框,带范围校验(如 0–100、0–4)
47
+ - 大多数键是热键,改动关闭面板后整屏重绘即刻可见;加载期固化的冷键常驻 `⟳ 需 /reload 生效` 标注,改动时会再提醒一次
48
+ - `foreignCardSkip` 黑名单只读展示,看得见状态又不会误编辑
49
+ - `/skin-setting default`:经确认对话框一键恢复全部默认(只清除本皮肤的配置键,文件里手写的其他内容保留)
50
+ - 非 TUI 模式(RPC / JSON / print)下运行会提示配置文件路径,请改用文件方式
51
+
52
+ 任何时刻可用 **F5** 整屏重绘(排残影的习惯操作)。
53
+
54
+ ### 手改配置文件
55
+
56
+ 也可以直接编辑 `~/.pi/agent/lean-tidy.json`(不存在时全部使用默认值)。手改后需 `/reload` 生效;设置面板的改动则是热键即时生效、冷键(下表标 `⟳` 者)需 `/reload`:
57
+
58
+ ```json
59
+ {
60
+ "profile": "claude",
61
+ "skillRendering": "native",
62
+ "subagentRendering": "claude",
63
+ "detailsBackground": "selectedBg",
64
+ "bashPreviewLines": 0,
65
+ "codePreviewLines": 0,
66
+ "errorPreviewLines": 0,
67
+ "liveThinking": true,
68
+ "thinkingTailLines": 5,
69
+ "foldAnimMs": 240,
70
+ "stickyIncludeCustom": false,
71
+ "skillResumePreview": true,
72
+ "resultPrefix": "└",
73
+ "resultPrefixGap": 1,
74
+ "normalizeForeignRenderers": true,
75
+ "workflowRendering": "claude",
76
+ "foreignCardStyle": "generic",
77
+ "foreignCardSkip": [],
78
+ "diff": {
79
+ "diffViewMode": "auto",
80
+ "diffIndicatorMode": "bars",
81
+ "diffSplitMinWidth": 120,
82
+ "editDiffCollapsedLines": 0,
83
+ "writeDiffCollapsedLines": 0,
84
+ "diffWordWrap": true,
85
+ "expandedPreviewMaxLines": 40
86
+ }
87
+ }
88
+ ```
89
+
90
+ | 键 | 默认 | 说明 |
91
+ |---|---|---|
92
+ | `profile` ⟳ | `"claude"` | 整体风格:`claude` / `native`(pi 原生) |
93
+ | `skillRendering` | `"native"` | skill 块样式:pi 原生折叠行 / `claude` 两行风格 |
94
+ | `subagentRendering` ⟳ | `"claude"` | 子代理完成通知:`claude` 分支风格 / `native` 保留包内面板 |
95
+ | `detailsBackground` | `"selectedBg"` | 详情背景色(主题色键名,可选 `customMessageBg`/`toolPendingBg`/`toolSuccessBg`/`toolErrorBg`/`""`) |
96
+ | `bash/code/errorPreviewLines` | `0`/`0`/`0` | 三类工具结果的预览行数(0–100;0 = 默认折叠) |
97
+ | `liveThinking` | `true` | 思考过程流式展示 |
98
+ | `thinkingTailLines` | `5` | 思考尾行数 |
99
+ | `foldAnimMs` | `240` | 折叠动画时长(ms,0 关闭) |
100
+ | `stickyIncludeCustom` ⟳ | `false` | 吸附泡是否包含扩展注入/跨会话转发的旁路消息 |
101
+ | `skillResumePreview` ⟳ | `true` | `/resume` 列表预览是否折回 `[skill] 名` 单行 |
102
+ | `resultPrefix` | `"└"` | 工具结果行拐角前缀(制表符族,等宽字体最稳;可换 `↳` 或 Claude Code 官方的 `⎿`,后两者在部分字体下会变宽) |
103
+ | `resultPrefixGap` | `1` | 前缀符号与内容之间的空格数(0–4) |
104
+ | `normalizeForeignRenderers` | `true` | 第三方扩展卡片输出里行首 `⎿` 自动规范化为 `resultPrefix`(任何未适配扩展的渲染都能统一风格;关闭可看第三方原版) |
105
+ | `workflowRendering` ⟳ | `"claude"` | workflow 完成卡片样式:`● Workflow(name)` 风格 / `native` 保留包内卡片。分发层拦截实现,不受包加载顺序影响 |
106
+ | `foreignCardStyle` | `"generic"` | 未适配扩展的卡片:自动接管为通用 `● 卡片`(零配置);`symbols` = 仅统一符号不接管布局 |
107
+ | `foreignCardSkip` | `[]` | 不接管的 customType 黑名单(交互型第三方卡片异常时按类型跳过) |
108
+ | `diff.diffViewMode` | `"auto"` | diff 视图:`auto`/`split`/`unified` |
109
+ | `diff.diffIndicatorMode` | `"bars"` | 变更指示条:`bars`/`classic`/`none` |
110
+ | `diff.diffSplitMinWidth` | `120` | 宽于此才用 split 视图 |
111
+ | `diff.edit/writeDiffCollapsedLines` | `0`/`0` | edit/write 默认折叠行数(0 = 默认展开) |
112
+ | `diff.diffWordWrap` | `true` | diff 自动换行 |
113
+ | `diff.expandedPreviewMaxLines` | `40` | 展开后最多显示行数 |
114
+
115
+ ## 兼容性
116
+
117
+ 按 `VERIFIED_PI_VERSION = 1.0.2` 验收;版本不一致时扩展会在 session 启动时提示一次。
118
+
119
+ ## 从源码安装
120
+
121
+ ```bash
122
+ pi install git:github.com/chenyn273/pi-claude-code-ui@v0.2.0
123
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yinan_chen/pi-claude-code-ui",
3
- "version": "0.8.9",
3
+ "version": "0.9.1",
4
4
  "description": "Claude Code-style UI for Pi: transcript view, rich diffs, subagent panels and working-message status.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,8 @@
20
20
  ],
21
21
  "files": [
22
22
  "src",
23
- "README.md"
23
+ "README.md",
24
+ "README.zh-CN.md"
24
25
  ],
25
26
  "pi": {
26
27
  "extensions": [
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
- const config: Config = {
102
- profile: "claude",
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
- const profile = (process.env.PI_LEAN_TIDY_PROFILE || "").trim();
151
- if (profile === "claude" || profile === "native") config.profile = profile;
152
- const skill = (process.env.PI_LEAN_TIDY_SKILL || "").trim();
153
- if (skill === "claude" || skill === "native") config.skillRendering = skill;
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,14 +564,80 @@ export default async function leanTidy(pi: ExtensionAPI): Promise<void> {
585
564
  return true;
586
565
  } catch { return false; }
587
566
  }
588
- pi.registerCommand("tidy-redraw", {
589
- description: "lean-tidy: 强制整屏重绘(清掉终端/pane 层留下的残影行)",
590
- handler: async (_args: string, ctx: any) => {
591
- if (!forceRedraw()) ctx.ui?.notify?.("lean-tidy: 拿不到 TUI,无法重绘", "warning");
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.reloadRequired) 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
+ "Restore default settings",
590
+ "This will delete all known skin config keys from lean-tidy.json (unknown keys are kept); live settings revert immediately. Continue?",
591
+ );
592
+ } catch { confirmed = false; }
593
+ if (!confirmed) return; // Esc / 取消:文件与运行时零改动。
594
+ const { reloadRequiredReset } = resetConfigFile(CONFIG_PATH);
595
+ // diff 显示配置先整体回出厂拷贝(文件 diff 里的未知键不留在运行时),
596
+ // 再走统一的热键 apply 通道逐键重置(幂等,值相同)。
597
+ diffDisplayConfig = { ...DEFAULT_TOOL_DISPLAY_CONFIG };
598
+ applyRuntimeDefaults(applySetting);
599
+ forceRedraw();
600
+ if (reloadRequiredReset.length > 0) ctx?.ui?.notify?.(`Defaults restored — ${reloadRequiredReset.length} setting(s) need /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: skin settings (default = reset to defaults)",
617
+ getArgumentCompletions(prefix: string) {
618
+ const items = [{ value: "default", label: "default", description: "Reset all settings to defaults (unknown keys are kept)" }];
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
+ `Unknown argument "${arg}". Usage: /skin-setting opens the settings panel (TUI only); /skin-setting default restores defaults. Config file: ${CONFIG_PATH}`,
634
+ "info",
635
+ );
592
636
  },
593
637
  });
638
+
594
639
  pi.registerShortcut("f5" as any, {
595
- description: "lean-tidy: 强制整屏重绘",
640
+ description: "lean-tidy: force full redraw",
596
641
  handler: () => { forceRedraw(); },
597
642
  });
598
643
 
@@ -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
+ reloadRequired: boolean;
43
+ }
44
+
45
+ /** 25 键元数据。默认值是唯一事实来源:loadConfig 经 topLevelDefaults() 消费,消除双源。 */
46
+ export const SETTING_DEFS: readonly SettingDef[] = [
47
+ // ---- 顶层 18 键 ----
48
+ {
49
+ id: "profile", label: "Profile", type: "enum", values: ["claude", "native"], defaultValue: "claude", reloadRequired: true,
50
+ description: "Overall look: claude = Claude Code-style views (default); native = pi native fallback.",
51
+ },
52
+ {
53
+ id: "skillRendering", label: "Skill rendering", type: "enum", values: ["native", "claude"], defaultValue: "native", reloadRequired: false,
54
+ description: "Skill invocation block style: native = pi native [skill] collapsed line (default); claude = ● Skill(name) two-line style.",
55
+ },
56
+ {
57
+ id: "subagentRendering", label: "Subagent rendering", type: "enum", values: ["native", "claude"], defaultValue: "claude", reloadRequired: true,
58
+ description: "Subagent completion notice style: claude = ● Subagent(desc) + branches (default); native = keep the bundled panel.",
59
+ },
60
+ {
61
+ id: "detailsBackground", label: "Details background", type: "enum",
62
+ values: ["selectedBg", "customMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg", ""],
63
+ defaultValue: "selectedBg", reloadRequired: false,
64
+ description: "Background color of the expanded tool details area (default selectedBg; empty string = no background).",
65
+ },
66
+ {
67
+ id: "bashPreviewLines", label: "Bash preview lines", type: "number", min: 0, max: 100, integer: true, defaultValue: 0, reloadRequired: false,
68
+ description: "Output preview lines for collapsed bash tool results, 0–100 (default 0).",
69
+ },
70
+ {
71
+ id: "codePreviewLines", label: "Code preview lines", type: "number", min: 0, max: 100, integer: true, defaultValue: 0, reloadRequired: false,
72
+ description: "Preview lines for collapsed read / write / edit / find tools, 0–100 (default 0).",
73
+ },
74
+ {
75
+ id: "errorPreviewLines", label: "Error preview lines", type: "number", min: 0, max: 100, integer: true, defaultValue: 0, reloadRequired: false,
76
+ description: "Preview lines for collapsed failed tool results, 0–100 (default 0).",
77
+ },
78
+ {
79
+ id: "liveThinking", label: "Live thinking stream", type: "boolean", defaultValue: true, reloadRequired: false,
80
+ description: "Stream thinking blocks as they arrive (default true).",
81
+ },
82
+ {
83
+ id: "thinkingTailLines", label: "Thinking tail lines", type: "number", min: 0, defaultValue: 5, reloadRequired: false,
84
+ description: "Lines kept at the tail when thinking is collapsed, ≥0 (default 5).",
85
+ },
86
+ {
87
+ id: "foldAnimMs", label: "Fold animation duration", type: "number", min: 0, defaultValue: 240, reloadRequired: false,
88
+ description: "Code block fold animation duration in milliseconds, ≥0 (default 240).",
89
+ },
90
+ {
91
+ id: "stickyIncludeCustom", label: "Sticky includes custom messages", type: "boolean", defaultValue: false, reloadRequired: true,
92
+ description: "Whether the sticky pool includes CustomMessages injected by extensions (default false).",
93
+ },
94
+ {
95
+ id: "skillResumePreview", label: "Resume preview fold-back", type: "boolean", defaultValue: true, reloadRequired: true,
96
+ description: "Whether the /resume session list preview folds back to [skill] names (default true).",
97
+ },
98
+ {
99
+ id: "resultPrefix", label: "Result line prefix", type: "string", defaultValue: "└", reloadRequired: false,
100
+ description: "Corner prefix symbol for tool result lines (default └; can be ↳ or ⎿).",
101
+ },
102
+ {
103
+ id: "resultPrefixGap", label: "Prefix gap", type: "number", min: 0, max: 4, integer: true, defaultValue: 1, reloadRequired: false,
104
+ description: "Spaces between the prefix symbol and content, 0–4 (default 1).",
105
+ },
106
+ {
107
+ id: "normalizeForeignRenderers", label: "Normalize foreign renderers", type: "boolean", defaultValue: true, reloadRequired: false,
108
+ description: "Rewrite the leading ⎿ of third-party extension output to resultPrefix (default true).",
109
+ },
110
+ {
111
+ id: "workflowRendering", label: "Workflow rendering", type: "enum", values: ["claude", "native"], defaultValue: "claude", reloadRequired: true,
112
+ description: "Workflow completion card style: claude = ● Workflow(name) (default); native = keep the bundled card.",
113
+ },
114
+ {
115
+ id: "foreignCardStyle", label: "Foreign card style", type: "enum", values: ["generic", "symbols"], defaultValue: "generic", reloadRequired: false,
116
+ description: "Cards for unmatched customTypes: generic = take over as a generic card (default); symbols = unify symbols only, keep layout.",
117
+ },
118
+ {
119
+ id: "foreignCardSkip", label: "Card takeover blocklist", type: "readonly-array", defaultValue: [], reloadRequired: false,
120
+ description: "customType blocklist never taken over (read-only; default []).",
121
+ },
122
+ // ---- diff.* 7 键(默认值直接引用 vendored DEFAULT_TOOL_DISPLAY_CONFIG,保持同源)----
123
+ {
124
+ id: "diff.diffViewMode", label: "Diff view mode", type: "enum", values: ["auto", "split", "unified"],
125
+ defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.diffViewMode, reloadRequired: false,
126
+ description: "Diff display mode: auto / split / unified (default auto).",
127
+ },
128
+ {
129
+ id: "diff.diffIndicatorMode", label: "Diff indicator", type: "enum", values: ["bars", "classic", "none"],
130
+ defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode, reloadRequired: false,
131
+ description: "Diff change indicator: bars / classic / none (default bars).",
132
+ },
133
+ {
134
+ id: "diff.diffSplitMinWidth", label: "Split min width", type: "number", min: 0,
135
+ defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.diffSplitMinWidth, reloadRequired: false,
136
+ description: "Minimum total width for split view; falls back to unified below it (default 120).",
137
+ },
138
+ {
139
+ id: "diff.editDiffCollapsedLines", label: "Edit collapsed lines", type: "number", min: 0,
140
+ defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.editDiffCollapsedLines, reloadRequired: false,
141
+ description: "Context lines shown when an edit diff is collapsed (default 0).",
142
+ },
143
+ {
144
+ id: "diff.writeDiffCollapsedLines", label: "Write collapsed lines", type: "number", min: 0,
145
+ defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.writeDiffCollapsedLines, reloadRequired: false,
146
+ description: "Context lines shown when a write diff is collapsed (default 0).",
147
+ },
148
+ {
149
+ id: "diff.diffWordWrap", label: "Diff word wrap", type: "boolean",
150
+ defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.diffWordWrap, reloadRequired: false,
151
+ description: "Wrap overly wide diff lines (default true).",
152
+ },
153
+ {
154
+ id: "diff.expandedPreviewMaxLines", label: "Expanded preview max lines", type: "number", min: 0,
155
+ defaultValue: DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines, reloadRequired: false,
156
+ description: "Maximum lines for expanded diff previews (default 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 RELOAD_REQUIRED_SETTING_IDS: readonly string[] = SETTING_DEFS.filter((def) => def.reloadRequired).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: `Unknown setting key: ${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} only accepts one of ${legal}` };
206
+ }
207
+ case "boolean":
208
+ return typeof value === "boolean"
209
+ ? { ok: true, value }
210
+ : { ok: false, error: `${def.label} only accepts true / false` };
211
+ case "number": {
212
+ if (typeof value !== "number" || !Number.isFinite(value)) {
213
+ return { ok: false, error: `${def.label} must be a number` };
214
+ }
215
+ if (def.integer && !Number.isInteger(value)) {
216
+ return { ok: false, error: `${def.label} must be an integer (${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} is out of range ${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} cannot be a blank string` };
228
+ case "readonly-array":
229
+ return { ok: false, error: `${def.label} is a read-only blocklist; edit the config file manually` };
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
+ reloadRequiredReset: 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 { reloadRequiredReset: [] };
297
+ const obj = readConfigObject(path);
298
+ const reloadRequiredReset: string[] = [];
299
+ for (const def of SETTING_DEFS) {
300
+ if (def.id.includes(".")) continue;
301
+ if (!(def.id in obj)) continue;
302
+ if (def.reloadRequired && obj[def.id] !== def.defaultValue) reloadRequiredReset.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 { reloadRequiredReset };
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.reloadRequired) 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 ? `Range ${def.min}–${upper} (integer)` : `Range ≥ ${def.min} (fractional values are floored)`;
72
+ }
73
+ return "non-blank after 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)} · current ${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 to save · Esc to cancel"), 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 尾部 (⟳ needs /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.reloadRequired ? `${def.label} ⟳` : def.label,
171
+ description: def.reloadRequired ? `${def.description} (⟳ needs /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
+ /** 标题边框行:── Skin Settings(…) ──,标题居中、随渲染宽度自适应(副句表达即改即存与输入即搜索)。 */
213
+ function titleBorder(fg: (color: string, text: string) => string): BorderLine {
214
+ return new BorderLine((width) => {
215
+ const title = " Skin Settings (auto-save on change · type to search) ";
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 reloadRequiredNotified = 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: `Failed to write ${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.reloadRequired && !reloadRequiredNotified.has(id)) {
295
+ reloadRequiredNotified.add(id);
296
+ notify(`⟳ ${def.label} saved — takes effect after /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(`Usage: /skin-setting opens the settings panel (TUI only); /skin-setting default restores defaults. Config file: ${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
+ }