dsh-tui-theme 0.7.0 → 0.7.2

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
@@ -117,6 +117,8 @@ dsh plugin --profile dsh-tui add -w dsh-tui-theme@latest
117
117
 
118
118
  三项装饰全关时状态行整体消失。另有仅 profile 层的开关(`cordis.patch.yml`,不出现在 /settings):`autoInstallThemes`、`statusEnabled`。
119
119
 
120
+ 上表就是本插件的**可编辑面**:卡片字段与 `src/liveConfig.ts` 的 `LIVE_CONFIG_KEYS` 必须逐键相等(`npm run verify` 双向断言),因为 dsh-TUI ≥ 0.11(`dsh-settings` ≥ 0.1.7)的设置服务不再接受插件注册命名空间,而是把**本插件 Config 里标了 volatile 的字段**投影成表单、以 **Loader 条目 id**(默认 `dsh-tui-theme`)作命名空间;保存直接写进 profile 补丁并即时生效。老宿主(≤ 0.10.x,`dsh-settings` ≤ 0.1.6)仍走插件自己注册命名空间的旧路径,行为不变。两条路径共用同一串命名空间,因此给插件行改过 id 的用户在新宿主上会以新 id 存取设置(旧代下改动 id 也会跟着走)。
121
+
120
122
  ## 受宿主限制、目前无法定制的部分
121
123
 
122
124
  以下元素的颜色/形态由 dsh-TUI 宿主**硬编码**,不读取任何主题键,主题 JSON 与插件接缝都覆盖不到(dsh-TUI 0.9.3 实测):
@@ -150,20 +152,29 @@ rm ~/.dsh-tui/theme-follow.json
150
152
  ## 开发
151
153
 
152
154
  ```sh
153
- npm install
155
+ npm install --include=dev # 见下方安装前提,本仓不提交 lockfile
154
156
  npm run build
155
157
  npm run verify
156
158
  npm run verify:package
159
+ # 代际门禁:本仓 devDependency 的 dsh-settings 是旧代,脚本会明确跳过并以 0 退出;
160
+ # 要真正验证 ≥0.1.7 的 Config 投影,把 DSH_SETTINGS_DIR 指到真机那份安装
161
+ DSH_SETTINGS_DIR="$HOME/.dsh/profiles/node_modules/@deepseek-ai/dsh-settings" npm run verify:settings
157
162
  DSH_TUI_ADAPTER_DIR=/path/to/dsh-TUI/lib/types/dsh-adapter \
158
163
  DSH_TUI_SOURCE_ROOT=/path/to/dsh-TUI-source \
159
164
  npm run verify:host
160
165
  ```
161
166
 
162
- `verify:host` 默认使用开发依赖中的 dsh-TUI(当前为 0.10.1)进行零配置验证;需要验证旧版或发布基线时,再显式指向同一版本的宿主 adapter 与源码。需要锁定版本时,额外设置 `DSH_TUI_EXPECTED_VERSION`。
167
+ 安装前提(这两条会让普通 `npm install` 失败或静默不装依赖):
168
+
169
+ - **本仓有意不提交 `package-lock.json`。** devDependency 里的宿主 tarball 内嵌 `@dsh-std/*`,其包内 manifest 仍声明 `workspace:*`;提交的 lockfile 会被 npm 忠实重放,于是 `npm install` 与 `npm ci` 都报 `EUNSUPPORTEDPROTOCOL`。删掉 lockfile 后 npm 会直接跳过 tarball 内的 bundle 目录,正常安装。请勿把 lockfile 提交回来(CI 的 `contract` 档会直接拦下)。
170
+ - **安装时不要带 `NODE_ENV=production`。** 该变量会让 npm 按 `omit=dev` 静默省略 devDependencies,表现为 `up to date` 但 `node_modules` 为空、随后 `tsc` 找不到。所以显式写 `--include=dev`(CI 里另外固定 `NODE_ENV=development`)。
171
+
172
+ `verify:host` 默认使用开发依赖中的 dsh-TUI(当前为 0.10.1)进行零配置验证,无需本地宿主源码检出;需要验证旧版或发布基线时,再显式指向同一版本的宿主 adapter 与源码。需要锁定版本时,额外设置 `DSH_TUI_EXPECTED_VERSION`。CI 只覆盖这条 0.10.1 零配置线,0.9.x 兼容回归仍需本机按上面的 env 指向对应宿主 worktree。
163
173
 
164
174
  主题调色板改起来最直接:编辑 `themes/*.json` 后重新 `npm run verify`,再删掉 `~/.dsh-tui/themes/` 下对应文件让插件重装。
165
175
 
166
176
  ## 兼容性
167
177
 
168
178
  - **dsh-TUI 版本下限:0.8.8**(状态行与设置面板;0.9.3 实测)。0.10.0 及更新版本使用运行时主题注册;0.10.1 起状态行经富状态视图按主题配色(更旧宿主自动回退无色标量行);更旧的宿主缺 `dsh-tui-extensions` 扩展面时,插件自动降级为“仅安装三套主题”,不报错。
179
+ - **设置服务两代都支持**:`dsh-settings` ≤ 0.1.6(dsh-TUI 0.9.x/0.10.x)走插件注册命名空间 + `scope.watch`;≥ 0.1.7(dsh-TUI 0.11+,`/settings` 曾显示「命名空间未注册」)走 Config volatile 字段投影 + `loader/volatile-update` 重读。判定按能力探测,不解析版本号;两条路径的失败都会写日志而不是静默。适配依据见 `docs/decisions/2026-09-26-settings-generation-adaptation.md`。
169
180
  - Node `^22.19 || >=24`,纯 ESM,MIT。
@@ -0,0 +1,61 @@
1
+ # 决策:dsh-settings 两代适配 —— Config volatile 投影 + 命名空间跟随 Loader 条目 id
2
+
3
+ - 日期:2026-09-26
4
+ - 状态:已采纳
5
+ - 来源:迁移清单(dsh-tui-find 仓的 `docs/decisions/2026-09-24-settings-generation-adaptation.md`,本决策是它在本仓的落地记录);代码审查条目 T1–T3(工作区 `REVIEW.md`)
6
+
7
+ ## 背景
8
+
9
+ **现象**:dsh-TUI 升到 0.11.0(`@deepseek-ai/dsh-settings@0.1.7-rc.1`、schemastery 3.18.4)后,`/settings` 里 **dsh-tui-theme 卡片右侧打上 `命名空间未注册` 徽标**,七个字段全部不可编辑。原实现(v0.7.1)在 `ctx.inject(['settings'])` 里调 `settings.register(PLUGIN_ID, schema)`:新代服务**没有** `register`,调用抛 `TypeError` 被 `catch {}` 静默吞掉 → 命名空间从未存在 → 徽标(`lib/types/settingsSection.js`)。
10
+
11
+ **根因**:`dsh-settings` 0.1.7 换代。旧代 `SettingsProvider` 由插件用 `register(ns, schema)` 自注册命名空间;新代 `SettingsForms` 改为:
12
+
13
+ - 命名空间 = **profile 条目 id**(本插件默认即 `dsh-tui-theme`,`cordis.patch.yml` 钉死);
14
+ - 表单 schema = 插件 **Cordis `Config`** 里标了 volatile 的字段(`volatileForm(schema)`,投影侧只读 `meta.volatile` 这份普通数据);
15
+ - `describe()` 只列 `volatileForm(Config)` 非空的条目;
16
+ - 写入经 `configEditor` 落进当前 profile 的补丁,loader 就地把 volatile 引用改值并以 `entry.fiber.ctx.emit(self, 'loader/volatile-update', paths)` 广播(`Context.filter` 只投给该条目自己的 fiber);
17
+ - 自带卡片的插件用 `configure({ auto: false }, ctx.fiber)` 关掉自动生成页,`configure` 对同一 fiber 二次调用会 throw。
18
+
19
+ 另有两条硬约束:`.volatile()` 直到 **schemastery 3.18.3** 才有(本仓基线 3.18.1/3.18.2 没有);volatile 字段在 `apply` 期是**引用不是值**(`Schema.resolve` 用 `createVolatile()` 包一层)。
20
+
21
+ ## 决策(本仓落地)
22
+
23
+ 1. **双路径按能力分支,永不解析版本号**:`typeof settings.register === 'function'` → 旧代(注册命名空间 + `scope.watch`);否则视为新代(`configure({ auto: false }, ctx.fiber)` + 读活配置 + 订阅 `loader/volatile-update`);两者皆无则 `info` 说明卡片本会话不可用。
24
+ 2. **可编辑集合只有一个事实来源**:`src/liveConfig.ts` 的 `LIVE_CONFIG_KEYS` 驱动 `Config` 的 volatile 标记(`src/index.ts` 的 `configFields` + `liveField`),卡片字段与它**双向对拍**(`npm run verify` 断言,`npm run verify:settings` 再对真宿主投影断言一次)。
25
+ 3. **`liveField` 两级能力探测**:先 `.volatile()`(3.18.3+,走框架自带校验),没有就把 `meta.volatile` 直接写 true(照抄 dsh-TUI #990 给自家 Config 的修法);`meta` 被冻结时安静退化为不标记。
26
+ 4. **命名空间取 Loader 条目 id**(`resolveSettingsNamespace`):新代 `describe()` 直接以 `entry.options.id` 当 ns 且不校验,而插件自有分区在 `tuiSettingsSections.register()` 里仍受 `^[a-z][a-z0-9_-]*$` 约束——entry id 合法时以它为准,否则回落 `PLUGIN_ID` 并 warn;两代共用同一串,卡片、旧代注册与宿主投影不可能互相错位。
27
+ 5. **apply 期 config 一律先解包**:`readConfigValues` 逐键解引用,判定按 **cosmokit 的 Volatile 协议**(`Symbol.for('cosmokit.volatile.write') in value`),`{ get }` 单键形状只作兜底。`apply` 里构造 `cordis` 层与 `readLive()` 探针都走它,杜绝把 ref 当字符串/布尔用(形状判定在 ref 多带一个普通键时就静默失效)。
28
+ 6. **失败不再静默**:旧代注册失败 warn(原为裸 `catch {}`,正是这次「无日志可查」的来源);新代下 entry id 不可用、或一个 live 字段都没有(`hasLiveConfigFields`)时各给一条可诊断的 warn;缺 `loader/volatile-update` 时 `info` 说明「改动下次加载才生效」。
29
+ 7. **新增代际验证入口**:`npm run verify:settings`(`scripts/verify-settings-generation.mjs`)——对真实安装的 dsh-settings 判定世代;≥0.1.7 时用**真实的** `volatileForm`/`projectForm`/`isVolatilePath` 对构建产物 `lib/types/` 跑 20 条投影断言,旧代明确报告「无需验证」并以 0 退出(CI 安全,已挂进 CI verify 档与 `release:check`)。
30
+ 8. **`settings.register` 必须按方法调用**(`settings.register?.(...)`,可选调用保留 receiver):真 provider 的 `register()` 读自身状态,摘成局部变量再调用会丢 `this` 抛错——这条由 `verify:host` 的真实 provider 相位咬出,已补进 `scripts/verify.mjs` 的假服务(`this === undefined` 即抛)作为回归。
31
+
32
+ ## 本仓特有的取舍
33
+
34
+ - **可编辑面 = 7 键**:`followSystem` / `showGlyph` / `statusGlyph` / `showClock` / `showTurns` / `statusSeparator` / `statusScope`,与卡片字段逐一对应。`autoInstallThemes` / `statusEnabled` **有意不标 live**:它们是仅 profile 层的开关(README 已写明不出现在 /settings),标了反而会出现「可编辑但无 UI」的错位。
35
+ - 新代下 setter 的取值来自插件自己的行配置,因此首帧 `onDoc` 携带的是**全量已解析配置**(含 schema 默认值),与旧代「只有用户层」的形状不同;`index.ts` 的 `{ ...cordis, ...doc }` 合并对两者都成立,`followActive` 的基线语义不变。
36
+ - `configure` 的 owner 必须是 **Config 所属的插件 fiber**(`apply` 的 `ctx.fiber`),`loader/volatile-update` 也注册在插件自身 context 上(loader 只投给被更新条目的 fiber);两者都挂在 inject 子 ctx 的 effect 上做清理。
37
+
38
+ ## 替代方案
39
+
40
+ - **抬高 peer 下限到 ≥0.1.7、只支持新代**:把 0.9.x/0.10.x 用户挡在门外,与既有双宿主兼容策略冲突——否决。
41
+ - **放弃卡片、让新代自动生成页面**(不调 `configure`):字段标签/分组/中英文案/hint 全丢,且与已注册的卡片重复——否决。
42
+ - **逐字段手写 `.volatile()`、不设 `LIVE_CONFIG_KEYS`**:卡片与 schema 的对应关系无人守,漂移即静默死卡——否决。
43
+ - **只探测 `.volatile()`、不做 meta 兜底**:3.18.1/3.18.2 上标记静默失效,整套设置页变死(#990 的病根)——否决。
44
+ - **按 #991 的方式在标记失败时启动即报错**:第三方插件会把 TUI 启动一起带走——改为 warn + 验证脚本兜底。
45
+
46
+ ## 影响
47
+
48
+ - 收益:dsh-TUI 0.11/新代设置服务下卡片恢复可编辑、保存即生效;0.9.x/0.10.x 行为不变;三类静默失败(注册被吞、标记失效、命名空间错位)各自有 warn、脚本与测试兜底。
49
+ - 成本:新增 `src/liveConfig.ts`;`settingsSection.ts` 多一条世代分支;`verify.mjs` 多 3 个场景;多一个代际脚本随包分发。
50
+ - 风险:
51
+ - 宿主 `loader/volatile-update` 语义再变 → 症状是「保存后不生效」,`verify:settings` 的重读断言会先红。
52
+ - schemastery 未来把 `.volatile()` 改名 **且** 冻结 `meta` → 标记彻底失效,`verify:settings` 的「live 标记存在」断言会红。
53
+ - 直接写 `meta.volatile` 绕过框架的 volatile 校验;本仓只在**扁平** Config(标的是叶子)上用它。
54
+ - 旧代下给插件行改过 id 的用户,`settings.yaml` 里旧 `dsh-tui-theme` 一节不再生效(键跟随新 id);默认安装 id 不变,且新代本来就以 entry id 为身份——接受并在此记录。
55
+ - 真机确认(2026-09-26):`verify:settings` 对真机 `0.1.7-rc.1` + schemastery 3.18.4 的 20 条断言全绿;构建产物装进本机 `dsh-tui` profile(原 0.7.1 目录整目录备份、profile 依赖声明未改),`dsh --profile dsh-tui --dump-config` 条目 id 与卡片 `ns` 一致;**用户肉眼确认 `/settings` 里 pink-theme 卡片已无 `命名空间未注册` 徽标**(见 `REVIEW.md` T4)。
56
+
57
+ ## 关联
58
+
59
+ - 迁移清单与上游动向:dsh-tui-find `docs/decisions/2026-09-24-settings-generation-adaptation.md`(含 dsh-TUI [#990](https://github.com/ccch1mneyyy/dsh-TUI/issues/990) / [PR #991](https://github.com/ccch1mneyyy/dsh-TUI/pull/991))
60
+ - 相关代码:`src/liveConfig.ts`、`src/settingsSection.ts`、`src/index.ts`
61
+ - 相关脚本:`scripts/verify-settings-generation.mjs`(`npm run verify:settings`)、`scripts/verify.mjs` 场景 2d/2e/2f
@@ -13,6 +13,9 @@
13
13
  */
14
14
  interface FollowCache {
15
15
  light: boolean;
16
+ /** Diagnostic only (the /settings panel shows its date); never an expiry
17
+ * signal — the cache is applied as-is whenever the user opts in, and this
18
+ * plugin has no producer to refresh it (see the module header). */
16
19
  at: number;
17
20
  }
18
21
  /** The persisted theme pref ({ theme: name }), host format. */
@@ -1 +1 @@
1
- {"version":3,"file":"autoTheme.d.ts","sourceRoot":"","sources":["../../src/autoTheme.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AASH,UAAU,WAAW;IACnB,KAAK,EAAE,OAAO,CAAA;IACd,EAAE,EAAE,MAAM,CAAA;CACX;AAsCD,+DAA+D;AAC/D,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGjE;AAED,yDAAyD;AACzD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAErE;AAED,sFAAsF;AACtF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CAGxE;AAED,yDAAyD;AACzD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEzD;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMrE;AAED,oFAAoF;AACpF,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,CAAA;AAE3B;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,OAAO,EACxB,MAAM,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,GACvC,IAAI,CAUN"}
1
+ {"version":3,"file":"autoTheme.d.ts","sourceRoot":"","sources":["../../src/autoTheme.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AASH,UAAU,WAAW;IACnB,KAAK,EAAE,OAAO,CAAA;IACd;;wEAEoE;IACpE,EAAE,EAAE,MAAM,CAAA;CACX;AAsCD,+DAA+D;AAC/D,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGjE;AAED,yDAAyD;AACzD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAErE;AAED,sFAAsF;AACtF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CAGxE;AAED,yDAAyD;AACzD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEzD;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMrE;AAED,oFAAoF;AACpF,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,aAAa,CAAA;CAAE,CAAA;AAE3B;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,OAAO,EACxB,MAAM,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,GACvC,IAAI,CAUN"}
@@ -7,7 +7,9 @@
7
7
  * - 缓存背景跟随:安全应用已有的终端背景缓存,在昼樱/夜樱间切换;
8
8
  * dsh-TUI 未提供插件终端查询接缝时,不直接读写 stdin、raw mode 或 OSC 11;
9
9
  * - 接缝十一(状态行):输入框上方一行小装饰(✿ · 时钟 · 本轮轮数);
10
- * - 接缝六(设置区块):/settings 里一个可编辑面板,即时生效。
10
+ * - 接缝六(设置区块):/settings 里一个可编辑面板,即时生效;宿主设置服务
11
+ * 换代后(dsh-settings ≥0.1.7,dsh-TUI ≥0.11)面板改由本插件 Config 里标了
12
+ * live 的字段投影,命名空间取 Loader 条目 id(liveConfig.ts / settingsSection.ts)。
11
13
  *
12
14
  * 遵循 #183 纪律:所有宿主服务用 ctx.inject 软探测,缺席即静默降级;
13
15
  * 插件缺失或宿主较旧时行为退化为“什么都没发生”,绝不拖垮启动。
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAMlD,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAKjF,eAAO,MAAM,IAAI,kBAAY,CAAA;AAE7B,wDAAwD;AACxD,MAAM,MAAM,MAAM,GAAG,eAAe,CAAA;AAUpC,eAAO,MAAM,MAAM,EAAE,WAAW,CAAC,MAAM,CAUrC,CAAA;AAoBF;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,MAAW,GAAG,IAAI,CAiK7D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAMlD,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAMjF,eAAO,MAAM,IAAI,kBAAY,CAAA;AAE7B,wDAAwD;AACxD,MAAM,MAAM,MAAM,GAAG,eAAe,CAAA;AAgCpC,eAAO,MAAM,MAAM,EAAE,WAAW,CAAC,MAAM,CAOtC,CAAA;AAoBD;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,MAAW,GAAG,IAAI,CAmL7D"}
@@ -7,7 +7,9 @@
7
7
  * - 缓存背景跟随:安全应用已有的终端背景缓存,在昼樱/夜樱间切换;
8
8
  * dsh-TUI 未提供插件终端查询接缝时,不直接读写 stdin、raw mode 或 OSC 11;
9
9
  * - 接缝十一(状态行):输入框上方一行小装饰(✿ · 时钟 · 本轮轮数);
10
- * - 接缝六(设置区块):/settings 里一个可编辑面板,即时生效。
10
+ * - 接缝六(设置区块):/settings 里一个可编辑面板,即时生效;宿主设置服务
11
+ * 换代后(dsh-settings ≥0.1.7,dsh-TUI ≥0.11)面板改由本插件 Config 里标了
12
+ * live 的字段投影,命名空间取 Loader 条目 id(liveConfig.ts / settingsSection.ts)。
11
13
  *
12
14
  * 遵循 #183 纪律:所有宿主服务用 ctx.inject 软探测,缺席即静默降级;
13
15
  * 插件缺失或宿主较旧时行为退化为“什么都没发生”,绝不拖垮启动。
@@ -22,17 +24,14 @@ import { runFollowSystem } from './autoTheme.js';
22
24
  import { registerPinkSettings } from './settingsSection.js';
23
25
  import { startShadowCleanup } from './shadowCleanup.js';
24
26
  import { startToastRelay } from './toast.js';
27
+ import { hasLiveConfigFields, isLiveConfigKey, liveField, readConfigValues } from './liveConfig.js';
25
28
  import { PLUGIN_ID } from './pluginId.js';
26
29
  export const name = PLUGIN_ID;
27
- // Explicit annotation: the inferred z.dict output references cosmokit's Dict
28
- // through a pnpm-virtual path, which is not portable in declaration emit
29
- // (TS2883). Mirrors the official plugin-template's workaround.
30
- //
31
- // statusEnabled / autoInstallThemes stay cordis-config knobs only (invisible
32
- // in /settings): turning the garnish off entirely is what uninstalling is
33
- // for, per user feedback. followSystem defaults off for generic installs;
34
- // the shipped cordis.patch.yml opts this deployment in.
35
- export const Config = z.object({
30
+ /** The row-config fields in declaration order. The live marking is applied
31
+ * afterwards from `LIVE_CONFIG_KEYS` (liveConfig.ts) so the /settings card's
32
+ * editable set and the marked set cannot drift apart — scripts/verify.mjs
33
+ * asserts both directions of that parity. */
34
+ const configFields = {
36
35
  autoInstallThemes: z.boolean().default(true),
37
36
  statusEnabled: z.boolean().default(true),
38
37
  followSystem: z.boolean().default(false),
@@ -42,7 +41,25 @@ export const Config = z.object({
42
41
  showClock: z.boolean().default(true),
43
42
  showTurns: z.boolean().default(true),
44
43
  statusScope: z.union(['pink-only', 'all-themes']).default('pink-only'),
45
- });
44
+ };
45
+ // Explicit annotation: the inferred z.dict output references cosmokit's Dict
46
+ // through a pnpm-virtual path, which is not portable in declaration emit
47
+ // (TS2883). Mirrors the official plugin-template's workaround.
48
+ //
49
+ // statusEnabled / autoInstallThemes stay cordis-config knobs only (invisible
50
+ // in /settings, and therefore not marked live): turning the garnish off
51
+ // entirely is what uninstalling is for, per user feedback. followSystem
52
+ // defaults off for generic installs; the shipped cordis.patch.yml opts this
53
+ // deployment in.
54
+ //
55
+ // On a `dsh-settings` ≥0.1.7 host the marked fields below ARE the /settings
56
+ // form schema (the namespace is the profile entry id); on ≤0.1.6 hosts they
57
+ // are ordinary config values and the card's values come from the namespace
58
+ // registration instead.
59
+ export const Config = z.object(Object.fromEntries(Object.entries(configFields).map(([key, field]) => [
60
+ key,
61
+ isLiveConfigKey(key) ? liveField(field) : field,
62
+ ])));
46
63
  const DEFAULTS = {
47
64
  autoInstallThemes: true,
48
65
  statusEnabled: true,
@@ -60,18 +77,22 @@ const DEFAULTS = {
60
77
  * @param config - Validated plugin config (schema defaults applied).
61
78
  */
62
79
  export function apply(ctx, config = {}) {
80
+ // A `dsh-settings` ≥0.1.7 host hands every marked field to apply as a live
81
+ // ref, not as the value it stands for (liveConfig.ts); unwrap before any
82
+ // read, here and in the readLive() probe the settings wiring re-runs.
83
+ const live = readConfigValues(config);
63
84
  // The cordis.yml layer: schema defaults plus explicit ?? fallbacks so a
64
85
  // bare composition still resolves every knob.
65
86
  const cordis = {
66
- autoInstallThemes: config.autoInstallThemes ?? DEFAULTS.autoInstallThemes,
67
- statusEnabled: config.statusEnabled ?? DEFAULTS.statusEnabled,
68
- followSystem: config.followSystem ?? DEFAULTS.followSystem,
69
- statusGlyph: config.statusGlyph ?? DEFAULTS.statusGlyph,
70
- statusSeparator: config.statusSeparator ?? DEFAULTS.statusSeparator,
71
- showGlyph: config.showGlyph ?? DEFAULTS.showGlyph,
72
- showClock: config.showClock ?? DEFAULTS.showClock,
73
- showTurns: config.showTurns ?? DEFAULTS.showTurns,
74
- statusScope: config.statusScope ?? DEFAULTS.statusScope,
87
+ autoInstallThemes: live.autoInstallThemes ?? DEFAULTS.autoInstallThemes,
88
+ statusEnabled: live.statusEnabled ?? DEFAULTS.statusEnabled,
89
+ followSystem: live.followSystem ?? DEFAULTS.followSystem,
90
+ statusGlyph: live.statusGlyph ?? DEFAULTS.statusGlyph,
91
+ statusSeparator: live.statusSeparator ?? DEFAULTS.statusSeparator,
92
+ showGlyph: live.showGlyph ?? DEFAULTS.showGlyph,
93
+ showClock: live.showClock ?? DEFAULTS.showClock,
94
+ showTurns: live.showTurns ?? DEFAULTS.showTurns,
95
+ statusScope: live.statusScope ?? DEFAULTS.statusScope,
75
96
  };
76
97
  // User-visible one-liners for the few events worth surfacing (the plugin
77
98
  // logger is invisible to a TUI user). Hosts without the 0.10 toast seam
@@ -94,6 +115,9 @@ export function apply(ctx, config = {}) {
94
115
  if (!cordis.autoInstallThemes)
95
116
  return;
96
117
  const result = installBundledThemes();
118
+ if (result.sourceError !== undefined) {
119
+ ctx.logger.warn(`${PLUGIN_ID}: ${result.sourceError}`);
120
+ }
97
121
  for (const file of result.installed) {
98
122
  ctx.logger.info(`${PLUGIN_ID}: installed bundled theme "${file}" into ~/.dsh-tui/themes/`);
99
123
  }
@@ -186,27 +210,34 @@ export function apply(ctx, config = {}) {
186
210
  }
187
211
  });
188
212
  };
189
- registerPinkSettings(ctx, cordis, doc => {
190
- effective = { ...cordis, ...doc };
191
- const follow = effective.followSystem === true;
192
- if (followActive === undefined) {
193
- // First document: align the baseline, not a switch. A value that only
194
- // matches the default logs nothing; a user layer that starts enabled
195
- // still applies the cache immediately.
196
- followActive = follow;
197
- if (follow)
198
- applyFollow(false);
199
- return;
200
- }
201
- if (followActive !== follow) {
202
- followActive = follow;
203
- if (follow) {
204
- applyFollow(true);
213
+ registerPinkSettings(ctx, {
214
+ cordis,
215
+ // The live row config: on ≥0.1.7 hosts the loader rewrites these refs in
216
+ // place, so re-reading after `loader/volatile-update` yields the edit.
217
+ readLive: () => readConfigValues(config),
218
+ hasLiveFields: hasLiveConfigFields(Config),
219
+ onDoc: doc => {
220
+ effective = { ...cordis, ...doc };
221
+ const follow = effective.followSystem === true;
222
+ if (followActive === undefined) {
223
+ // First document: align the baseline, not a switch. A value that only
224
+ // matches the default logs nothing; a user layer that starts enabled
225
+ // still applies the cache immediately.
226
+ followActive = follow;
227
+ if (follow)
228
+ applyFollow(false);
229
+ return;
205
230
  }
206
- else {
207
- ctx.logger.info(`${PLUGIN_ID}: follow: disabled, manual /theme choice preserved`);
231
+ if (followActive !== follow) {
232
+ followActive = follow;
233
+ if (follow) {
234
+ applyFollow(true);
235
+ }
236
+ else {
237
+ ctx.logger.info(`${PLUGIN_ID}: follow: disabled, manual /theme choice preserved`);
238
+ }
208
239
  }
209
- }
240
+ },
210
241
  }, dataDir);
211
242
  // The follow decision is owned entirely by the /settings layer above: there
212
243
  // is no timer or fallback path on hosts without a settings service — the
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The live-editable slice of the plugin's row config — the support the
3
+ * `dsh-settings` ≥0.1.7 generation needs (dsh-TUI 0.11+) while the ≤0.1.6
4
+ * generation keeps working unchanged.
5
+ *
6
+ * Two host generations read the same {@link Config} differently, and both must
7
+ * keep working (docs/decisions/… in the dsh-tui-find repo,
8
+ * `2026-09-24-settings-generation-adaptation.md`, is the reference record):
9
+ *
10
+ * - `dsh-settings` ≤0.1.6 (dsh-TUI 0.9.x/0.10.x): the `/settings` namespace is
11
+ * registered by the plugin and its form values come from that registration
12
+ * schema (settingsSection.ts). The row config is a frozen value.
13
+ * - `dsh-settings` ≥0.1.7 (dsh-TUI 0.11+): there is no registration API — the
14
+ * namespace is the profile entry id and the form schema is *this* schema
15
+ * projected onto the fields carrying the volatile marker. Those fields then
16
+ * reach `apply` as live refs whose value the loader rewrites in place, so
17
+ * every read goes through {@link readConfigValues} first.
18
+ *
19
+ * `.volatile()` landed in schemastery 3.18.3 while this plugin's baseline is
20
+ * 3.18.1/3.18.2, hence the capability probe in {@link liveField} — never a
21
+ * version parse. A plugin that misses the transition still boots, still
22
+ * registers its card, and simply renders `命名空间未注册`; a card field whose
23
+ * Config key is not marked renders `(未设置)` forever. Both are silent, which
24
+ * is why the marking is driven by one exported key list (the card↔schema
25
+ * parity is asserted in scripts/verify.mjs and
26
+ * scripts/verify-settings-generation.mjs).
27
+ *
28
+ * @module dsh-tui-theme/liveConfig
29
+ */
30
+ /**
31
+ * Keys the `/settings` card owns, and therefore the keys the host may edit
32
+ * live. On `dsh-settings` ≥0.1.7 these are exactly the fields the plugin's
33
+ * form projects, so the card's field paths, this list and the marked fields
34
+ * must stay equal — a card field outside the list would render editable yet
35
+ * never be served (the host's own status-bar `cost` field shows that failure
36
+ * mode as `(未设置)` forever), and a marked key without a field would be
37
+ * live-editable with no UI. `autoInstallThemes` / `statusEnabled` are
38
+ * deliberately absent: they stay cordis-config knobs (README §配置).
39
+ */
40
+ export declare const LIVE_CONFIG_KEYS: readonly ["followSystem", "showGlyph", "statusGlyph", "showClock", "showTurns", "statusSeparator", "statusScope"];
41
+ /** Whether one row-config key is part of the card's editable set. */
42
+ export declare function isLiveConfigKey(key: string): boolean;
43
+ /**
44
+ * Mark one schema field live-editable when the host can project it.
45
+ *
46
+ * Two capability steps, never a version parse:
47
+ *
48
+ * 1. `.volatile()` — schemastery 3.18.3+; returns a marked clone and runs the
49
+ * framework's own volatile validation, so it is preferred wherever it
50
+ * exists.
51
+ * 2. `meta.volatile = true` — the settings projection only reads that plain
52
+ * meta field, so writing it directly works on the 3.18.1/3.18.2 baselines
53
+ * too. This is the fallback dsh-TUI shipped for its own Config after issue
54
+ * #990 (its `editableConfig` silently no-opped on older schemastery and the
55
+ * whole settings page went dead on a 0.1.7 host).
56
+ *
57
+ * Calling `.volatile()` twice throws, so the marker is applied exactly once,
58
+ * from the single {@link LIVE_CONFIG_KEYS} list. A schemastery that froze its
59
+ * `meta` leaves the field unmarked (a dead card, but no boot failure — the
60
+ * host's own choice for third-party plugins).
61
+ */
62
+ export declare function liveField<T>(field: T): T;
63
+ /**
64
+ * Read a row config down to plain values.
65
+ *
66
+ * On `dsh-settings` ≥0.1.7 (schemastery ≥3.18.3) the loader hands every
67
+ * volatile field to `apply` as a live ref — a frozen object carrying the
68
+ * cosmokit Volatile protocol whose value the loader rewrites in place — so
69
+ * reading the config object again after `loader/volatile-update` yields the
70
+ * edited values (settingsSection.ts rides exactly that). Structural, not an
71
+ * import: the refs are cosmokit's Volatile protocol and cosmokit is not a
72
+ * dependency of this plugin. The row config is flat, so one unwrap level per
73
+ * key is enough; a nested knob would need a recursive walk here.
74
+ */
75
+ export declare function readConfigValues<T extends object>(config: T | undefined): T;
76
+ /**
77
+ * Whether a row-config schema carries the live marker anywhere — exactly what
78
+ * a `dsh-settings` ≥0.1.7 host needs to serve this plugin's `/settings` page
79
+ * (`volatileForm()` returns undefined without it and the entry never reaches
80
+ * `describe()`, which the TUI renders as `命名空间未注册`).
81
+ *
82
+ * Read structurally: the schemastery 3.18.1/3.18.2 types this repo builds
83
+ * against do not expose `.dict` on the schema type, and the caller owns the
84
+ * schema. settingsSection.ts warns with this when a new-generation host cannot
85
+ * serve the card, instead of leaving a bare badge as the only clue.
86
+ */
87
+ export declare function hasLiveConfigFields(schema: unknown): boolean;
88
+ //# sourceMappingURL=liveConfig.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"liveConfig.d.ts","sourceRoot":"","sources":["../../src/liveConfig.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,mHAQnB,CAAA;AAEV,qEAAqE;AACrE,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CAYxC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,MAAM,EAAE,MAAM,EAAE,CAAC,GAAG,SAAS,GAAG,CAAC,CAI3E;AAiCD;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAI5D"}
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The live-editable slice of the plugin's row config — the support the
3
+ * `dsh-settings` ≥0.1.7 generation needs (dsh-TUI 0.11+) while the ≤0.1.6
4
+ * generation keeps working unchanged.
5
+ *
6
+ * Two host generations read the same {@link Config} differently, and both must
7
+ * keep working (docs/decisions/… in the dsh-tui-find repo,
8
+ * `2026-09-24-settings-generation-adaptation.md`, is the reference record):
9
+ *
10
+ * - `dsh-settings` ≤0.1.6 (dsh-TUI 0.9.x/0.10.x): the `/settings` namespace is
11
+ * registered by the plugin and its form values come from that registration
12
+ * schema (settingsSection.ts). The row config is a frozen value.
13
+ * - `dsh-settings` ≥0.1.7 (dsh-TUI 0.11+): there is no registration API — the
14
+ * namespace is the profile entry id and the form schema is *this* schema
15
+ * projected onto the fields carrying the volatile marker. Those fields then
16
+ * reach `apply` as live refs whose value the loader rewrites in place, so
17
+ * every read goes through {@link readConfigValues} first.
18
+ *
19
+ * `.volatile()` landed in schemastery 3.18.3 while this plugin's baseline is
20
+ * 3.18.1/3.18.2, hence the capability probe in {@link liveField} — never a
21
+ * version parse. A plugin that misses the transition still boots, still
22
+ * registers its card, and simply renders `命名空间未注册`; a card field whose
23
+ * Config key is not marked renders `(未设置)` forever. Both are silent, which
24
+ * is why the marking is driven by one exported key list (the card↔schema
25
+ * parity is asserted in scripts/verify.mjs and
26
+ * scripts/verify-settings-generation.mjs).
27
+ *
28
+ * @module dsh-tui-theme/liveConfig
29
+ */
30
+ /**
31
+ * Keys the `/settings` card owns, and therefore the keys the host may edit
32
+ * live. On `dsh-settings` ≥0.1.7 these are exactly the fields the plugin's
33
+ * form projects, so the card's field paths, this list and the marked fields
34
+ * must stay equal — a card field outside the list would render editable yet
35
+ * never be served (the host's own status-bar `cost` field shows that failure
36
+ * mode as `(未设置)` forever), and a marked key without a field would be
37
+ * live-editable with no UI. `autoInstallThemes` / `statusEnabled` are
38
+ * deliberately absent: they stay cordis-config knobs (README §配置).
39
+ */
40
+ export const LIVE_CONFIG_KEYS = [
41
+ 'followSystem',
42
+ 'showGlyph',
43
+ 'statusGlyph',
44
+ 'showClock',
45
+ 'showTurns',
46
+ 'statusSeparator',
47
+ 'statusScope',
48
+ ];
49
+ /** Whether one row-config key is part of the card's editable set. */
50
+ export function isLiveConfigKey(key) {
51
+ return LIVE_CONFIG_KEYS.includes(key);
52
+ }
53
+ /**
54
+ * Mark one schema field live-editable when the host can project it.
55
+ *
56
+ * Two capability steps, never a version parse:
57
+ *
58
+ * 1. `.volatile()` — schemastery 3.18.3+; returns a marked clone and runs the
59
+ * framework's own volatile validation, so it is preferred wherever it
60
+ * exists.
61
+ * 2. `meta.volatile = true` — the settings projection only reads that plain
62
+ * meta field, so writing it directly works on the 3.18.1/3.18.2 baselines
63
+ * too. This is the fallback dsh-TUI shipped for its own Config after issue
64
+ * #990 (its `editableConfig` silently no-opped on older schemastery and the
65
+ * whole settings page went dead on a 0.1.7 host).
66
+ *
67
+ * Calling `.volatile()` twice throws, so the marker is applied exactly once,
68
+ * from the single {@link LIVE_CONFIG_KEYS} list. A schemastery that froze its
69
+ * `meta` leaves the field unmarked (a dead card, but no boot failure — the
70
+ * host's own choice for third-party plugins).
71
+ */
72
+ export function liveField(field) {
73
+ const candidate = field;
74
+ if (typeof candidate.volatile === 'function')
75
+ return candidate.volatile();
76
+ const meta = candidate.meta;
77
+ if (typeof meta === 'object' && meta !== null) {
78
+ try {
79
+ meta.volatile = true;
80
+ }
81
+ catch {
82
+ return field;
83
+ }
84
+ }
85
+ return field;
86
+ }
87
+ /**
88
+ * Read a row config down to plain values.
89
+ *
90
+ * On `dsh-settings` ≥0.1.7 (schemastery ≥3.18.3) the loader hands every
91
+ * volatile field to `apply` as a live ref — a frozen object carrying the
92
+ * cosmokit Volatile protocol whose value the loader rewrites in place — so
93
+ * reading the config object again after `loader/volatile-update` yields the
94
+ * edited values (settingsSection.ts rides exactly that). Structural, not an
95
+ * import: the refs are cosmokit's Volatile protocol and cosmokit is not a
96
+ * dependency of this plugin. The row config is flat, so one unwrap level per
97
+ * key is enough; a nested knob would need a recursive walk here.
98
+ */
99
+ export function readConfigValues(config) {
100
+ const plain = {};
101
+ for (const [key, value] of Object.entries(config ?? {}))
102
+ plain[key] = readRef(value);
103
+ return plain;
104
+ }
105
+ /**
106
+ * The cosmokit Volatile brand, read by name so it matches the host's own copy
107
+ * across module instances (`cosmokit/src/volatile.ts`). Absent from the
108
+ * schemastery 3.18.1/3.18.2 generations this plugin also supports — probing it
109
+ * is a plain symbol lookup, never a version parse, and the shape fallback
110
+ * below still covers a hand-rolled ref.
111
+ */
112
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write');
113
+ /**
114
+ * Unwrap one live config ref, if that is what it is.
115
+ *
116
+ * The protocol brand (`cosmokit.volatile.write` in the value, exactly what
117
+ * `isVolatile` checks) is authoritative and is probed FIRST: a real ref may
118
+ * legitimately carry more than the `get` key, and the shape heuristic below
119
+ * would then hand the ref object itself to the caller, which reads it as "not
120
+ * a string/boolean" and silently falls back to the documented default — no
121
+ * warning, no error. The shape check stays as a fallback for a ref built
122
+ * without the brand.
123
+ */
124
+ function readRef(value) {
125
+ if (typeof value !== 'object' || value === null)
126
+ return value;
127
+ const ref = value;
128
+ const branded = VOLATILE_WRITE in value;
129
+ if (!branded) {
130
+ const keys = Object.keys(value);
131
+ if (keys.length !== 1 || keys[0] !== 'get')
132
+ return value;
133
+ }
134
+ return typeof ref.get === 'function' ? readRef(ref.get()) : value;
135
+ }
136
+ /**
137
+ * Whether a row-config schema carries the live marker anywhere — exactly what
138
+ * a `dsh-settings` ≥0.1.7 host needs to serve this plugin's `/settings` page
139
+ * (`volatileForm()` returns undefined without it and the entry never reaches
140
+ * `describe()`, which the TUI renders as `命名空间未注册`).
141
+ *
142
+ * Read structurally: the schemastery 3.18.1/3.18.2 types this repo builds
143
+ * against do not expose `.dict` on the schema type, and the caller owns the
144
+ * schema. settingsSection.ts warns with this when a new-generation host cannot
145
+ * serve the card, instead of leaving a bare badge as the only clue.
146
+ */
147
+ export function hasLiveConfigFields(schema) {
148
+ const dict = schema
149
+ ?.dict;
150
+ return Object.values(dict ?? {}).some(field => field?.meta?.volatile === true);
151
+ }