dsh-tui-theme 0.4.0 → 0.5.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 CHANGED
@@ -15,36 +15,34 @@
15
15
 
16
16
  | 主题 | 基底 | 风格 |
17
17
  | --- | --- | --- |
18
- | `pink-night` 夜樱 | dark | 深梅紫底、玫瑰粉强调、粉鲸鱼吉祥物,95 键全覆盖 |
18
+ | `pink-night` 夜樱 | dark | 暗梅底、玫瑰粉强调,95 键全覆盖 |
19
19
  | `pink-day` 昼樱 | light | 象牙粉底、墨梅正文、柔和玫瑰强调(已通过宿主浅色身份判定) |
20
20
  | `pink-ansi` 樱·ANSI | dark-ansi | 16 色 ANSI 回退,品牌色映射到 magenta 系 |
21
21
 
22
22
  三套均通过 dsh-TUI 官方校验器(零警告、全键覆盖)与 WCAG 对比度检查(正文 ≥ 11:1)。
23
23
 
24
- > 小知识:`pink-day` 的 `text` 写成 `rgb(61,43,51)` 而非 hex——宿主按 `text` 墨色亮度自动判定主题深浅,且只认 `rgb()` 格式。
25
-
26
24
  ## 截图
27
25
 
28
- 实测于 dsh-tui 0.9.2
26
+ 主题主界面截图实测于 dsh-tui 0.9.2;0.9.3 的 `/settings` 卡片界面见下图:
29
27
 
30
28
  | 昼樱 `pink-day` | 夜樱 `pink-night` |
31
29
  | :---: | :---: |
32
30
  | ![pink-day 主题界面](docs/screenshots/pink-day.png) | ![pink-night 主题界面](docs/screenshots/pink-night.png) |
33
31
 
34
- `/settings` 里的 pink-theme 区块(已缓存背景 / 花符 / 时钟 / 轮数,保存即时生效):
32
+ `/settings` 里的 pink-theme 区块(终端背景 / 花符 / 时钟 / 轮数 / 状态行展示,保存即时生效):
35
33
 
36
34
  ![pink-theme 设置区块](docs/screenshots/settings.png)
37
35
 
38
- > 图中底栏上下文进度条的蓝色分段与提示符是宿主硬编码的,见下文[宿主限制](#受宿主限制目前无法定制的部分)。
36
+ > 图中提示符与链接是宿主硬编码的,见下文[宿主限制](#受宿主限制目前无法定制的部分)。
39
37
 
40
38
  ## 缓存背景跟随
41
39
 
42
- `dsh-TUI 0.9.2` 没有向插件公开安全的终端查询接缝。为了不与宿主的 stdin/raw-mode 生命周期竞争,插件不会直接发送 OSC 11,也不会读取终端输入。
40
+ `dsh-TUI` 没有向插件公开安全的终端查询接缝。为了不与宿主的 stdin/raw-mode 生命周期竞争,插件不会直接发送 OSC 11,也不会读取终端输入。
43
41
 
44
- “使用已缓存的终端背景”开启后:
42
+ “应用上次保存的终端背景”开启后:
45
43
 
46
44
  - 启动时读取 `~/.dsh-tui/theme-follow.json` 中已有的 `light` 结果,亮 → `pink-day`,暗 → `pink-night`,并将选择写入 `~/.dsh-tui/theme.json`;
47
- - 不存在缓存时完全保留当前 `/theme` 选择;0.4.0 不会自行创建或刷新缓存;
45
+ - 不存在缓存时完全保留当前 `/theme` 选择;插件不会自行创建或刷新缓存;
48
46
  - 默认关闭。该缓存可由之前的兼容版本留下;未来 dsh-TUI 提供宿主拥有的查询接缝后,插件才会安全地恢复刷新能力;
49
47
  - 开启后该缓存会在启动时覆盖 `/theme` 的持久选择;关闭即可恢复手动选择;
50
48
  - `DSH_TUI_THEME` 环境变量仍然最优先(宿主行为,插件不覆盖环境变量)。
@@ -59,7 +57,7 @@ dsh plugin --profile dsh-tui add -w dsh-tui-theme@latest
59
57
  cd /path/to/dsh-tui-theme
60
58
  npm run build
61
59
  npm pack
62
- dsh plugin --profile dsh-tui add -w ./dsh-tui-theme-0.4.0.tgz
60
+ dsh plugin --profile dsh-tui add -w ./dsh-tui-theme-<版本号>.tgz
63
61
  ```
64
62
 
65
63
  不要以本地源码目录作为依赖安装:其开发 `node_modules` 可能与 dsh-TUI 宿主解析出不同的 Cordis/DSH framework instance,造成插件无法注册服务。
@@ -71,7 +69,7 @@ dsh plugin --profile dsh-tui add -w ./dsh-tui-theme-0.4.0.tgz
71
69
  dsh plugin --profile dsh-tui add -w dsh-tui-theme@latest
72
70
  ```
73
71
 
74
- 插件只在主题文件缺失时复制,绝不覆盖你编辑过的 `~/.dsh-tui/themes/pink-*.json`。升级若需要采用包内的新主题资产,请先备份自己的修改,再删除对应主题文件并重启 dsh-TUI 让插件重装。
72
+ 插件只在主题文件缺失时复制,绝不覆盖你编辑过的 `~/.dsh-tui/themes/pink-*.json`。唯一的例外是已损坏的目标文件(无法解析为 JSON,例如安装中途中断留下的残文件):插件会把它改名为 `<文件名>.corrupt-<时间戳>` 保留现场,再重新安装内置副本,并记录一条警告。升级若需要采用包内的新主题资产,请先备份自己的修改,再删除对应主题文件并重启 dsh-TUI 让插件重装。
75
73
 
76
74
  重启 dsh-TUI 后插件自动把三套主题复制进 `~/.dsh-tui/themes/`(**仅缺失时复制,绝不覆盖你已有的同名文件**),然后:
77
75
 
@@ -89,7 +87,7 @@ dsh plugin --profile dsh-tui add -w dsh-tui-theme@latest
89
87
 
90
88
  | 字段 | 默认 | 说明 |
91
89
  | --- | --- | --- |
92
- | `followSystem` | `false` | 启动时应用已缓存的终端背景结果(昼樱夜樱);0.4.0 不刷新缓存 |
90
+ | `followSystem` | `false` | 启动时应用上次保存的终端背景结果(昼樱夜樱);插件不刷新缓存 |
93
91
  | `showGlyph` | `true` | 花符:开 = ✿ 开头,关 = 不显示 |
94
92
  | `showClock` | `true` | 显示 HH:MM 时钟 |
95
93
  | `showTurns` | `true` | 显示当前会话轮数(`N✦`,自本次启动起计) |
@@ -99,7 +97,7 @@ dsh plugin --profile dsh-tui add -w dsh-tui-theme@latest
99
97
 
100
98
  ## 受宿主限制、目前无法定制的部分
101
99
 
102
- 以下元素的颜色/形态由 dsh-TUI 宿主**硬编码**,不读取任何主题键,主题 JSON 与插件接缝都覆盖不到(dsh-TUI 0.9.2 实测):
100
+ 以下元素的颜色/形态由 dsh-TUI 宿主**硬编码**,不读取任何主题键,主题 JSON 与插件接缝都覆盖不到(dsh-TUI 0.9.3 实测):
103
101
 
104
102
  | 元素 | 现状 | 位置(宿主源码) |
105
103
  | --- | --- | --- |
@@ -108,6 +106,7 @@ dsh plugin --profile dsh-tui add -w dsh-tui-theme@latest
108
106
  | 进度条空余段配色 | 宿主按 `themeName === 'light'` **字符串比较**取浅色配色——自定义浅色主题(如 pink-day)不等于 `'light'`,会拿到深色空余段,在浅色终端上偏深 | `screens/StatusLine.tsx` |
109
107
  | 状态行文字颜色 | 插件状态行(tuiStatus)由宿主统一以**无色 + 终端 dim** 渲染,插件无法指定颜色(✿ 行因此继承终端默认前景色) | `screens/Chat.tsx` |
110
108
  | 输入框块状光标 | 宿主挂载期间隐藏终端原生光标(`?25l`),输入框光标由应用以**反色字符**自绘(`<Text inverse>`),颜色即主题 text/background 的反色——OSC 12 光标色只能染到不可见的原生光标,插件无法给输入光标上色(辅助功能模式 `CLAUDE_CODE_ACCESSIBILITY=1` 下原生光标才可见) | `ink/components/App.tsx`、`components/PromptInput.tsx` |
109
+ | 正文链接 | OSC 8 超链接默认**写死的 ANSI 蓝**(`chalk.blue`);注释说明 wrap-ansi 无法跨 OSC 8 保留主题 RGB 色,故链接色不读主题键 | `cc/hyperlink.ts` |
111
110
  | 主界面组件与布局 | 顶栏像素鲸鱼、工具卡、输入框等宿主组件不可被插件替换或改布局——平台规则(内建优先,无组件替换接缝);主题能碰的只有颜色层 | 宿主架构约定 |
112
111
 
113
112
  这些都需要上游 dsh-TUI 修改(例如:把充能色/进度条分段色接入主题键、空余段判断改用 `isLightThemeActive()`、给输入光标增加主题键)。上游修复前,任何社区主题包都受同样约束。
@@ -136,11 +135,11 @@ DSH_TUI_SOURCE_ROOT=/path/to/dsh-TUI-source \
136
135
  npm run verify:host
137
136
  ```
138
137
 
139
- `verify:host` 必须显式指向待兼容的宿主 adapter 与源码;缺少其中任一项会失败,避免把跳过误报为验证成功。
138
+ `verify:host` 必须显式指向同一版本的宿主 adapter 与源码;缺少其中任一项会失败,避免把跳过误报为验证成功。日常开发会验证两者版本一致但不固定版本号;需要锁定一次发布基线时,额外设置 `DSH_TUI_EXPECTED_VERSION=0.9.3`。
140
139
 
141
140
  主题调色板改起来最直接:编辑 `themes/*.json` 后重新 `npm run verify`,再删掉 `~/.dsh-tui/themes/` 下对应文件让插件重装。
142
141
 
143
142
  ## 兼容性
144
143
 
145
- - **dsh-TUI 版本下限:0.8.8**(状态行与设置面板;0.9.2 实测)。更旧的宿主缺 `dsh-tui-extensions` 扩展面时,插件自动降级为“仅安装三套主题”,不报错。
144
+ - **dsh-TUI 版本下限:0.8.8**(状态行与设置面板;0.9.3 实测)。更旧的宿主缺 `dsh-tui-extensions` 扩展面时,插件自动降级为“仅安装三套主题”,不报错。
146
145
  - Node `^22.19 || >=24`,纯 ESM,MIT。
Binary file
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cached terminal-background follow for the pink theme pair.
3
3
  *
4
- * dsh-TUI v0.9.2 does not expose a plugin terminal-query seam. Plugins must
4
+ * dsh-TUI does not expose a plugin terminal-query seam. Plugins must
5
5
  * therefore not create their own stdin consumers or raw-mode leases: the host
6
6
  * owns both through Ink. This module only applies a previously stored result
7
7
  * before mount. A future host-owned query service can refresh that cache
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cached terminal-background follow for the pink theme pair.
3
3
  *
4
- * dsh-TUI v0.9.2 does not expose a plugin terminal-query seam. Plugins must
4
+ * dsh-TUI does not expose a plugin terminal-query seam. Plugins must
5
5
  * therefore not create their own stdin consumers or raw-mode leases: the host
6
6
  * owns both through Ink. This module only applies a previously stored result
7
7
  * before mount. A future host-owned query service can refresh that cache
@@ -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;AAKlD,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAEjF,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAEnC,wDAAwD;AACxD,MAAM,MAAM,MAAM,GAAG,eAAe,CAAA;AAUpC,eAAO,MAAM,MAAM,EAAE,WAAW,CAAC,MAAM,CAQrC,CAAA;AAyBF;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,MAAW,GAAG,IAAI,CA2D7D"}
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;AAKlD,OAAO,EAAwB,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAGjF,eAAO,MAAM,IAAI,kBAAY,CAAA;AAE7B,wDAAwD;AACxD,MAAM,MAAM,MAAM,GAAG,eAAe,CAAA;AAUpC,eAAO,MAAM,MAAM,EAAE,WAAW,CAAC,MAAM,CAQrC,CAAA;AAkBF;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,MAAW,GAAG,IAAI,CA0E7D"}
@@ -19,7 +19,8 @@ import { installBundledThemes, homeDir } from './themeAssets.js';
19
19
  import { startStatusLine } from './statusLine.js';
20
20
  import { runFollowSystem } from './autoTheme.js';
21
21
  import { registerPinkSettings } from './settingsSection.js';
22
- export const name = 'dsh-tui-theme';
22
+ import { PLUGIN_ID } from './pluginId.js';
23
+ export const name = PLUGIN_ID;
23
24
  // Explicit annotation: the inferred z.dict output references cosmokit's Dict
24
25
  // through a pnpm-virtual path, which is not portable in declaration emit
25
26
  // (TS2883). Mirrors the official plugin-template's workaround.
@@ -46,12 +47,6 @@ const DEFAULTS = {
46
47
  showTurns: true,
47
48
  statusScope: 'pink-only',
48
49
  };
49
- /**
50
- * Backstop delay for the cordis-layer follow decision on hosts without a
51
- * settings service (see apply). Stays under the host's own 300ms pre-mount
52
- * settings gate so the pref write still lands before first paint.
53
- */
54
- const FOLLOW_FALLBACK_MS = 150;
55
50
  /**
56
51
  * Wire the pink theme plugin.
57
52
  * @param ctx - Cordis context (the plugin's own activation).
@@ -72,16 +67,19 @@ export function apply(ctx, config = {}) {
72
67
  if (cordis.autoInstallThemes) {
73
68
  const result = installBundledThemes();
74
69
  for (const file of result.installed) {
75
- ctx.logger.info(`dsh-tui-theme: installed bundled theme "${file}" into ~/.dsh-tui/themes/`);
70
+ ctx.logger.info(`${PLUGIN_ID}: installed bundled theme "${file}" into ~/.dsh-tui/themes/`);
71
+ }
72
+ for (const file of result.repaired) {
73
+ ctx.logger.warn(`${PLUGIN_ID}: found a corrupt "${file}" in ~/.dsh-tui/themes/, backed it up and reinstalled the bundled copy`);
76
74
  }
77
75
  for (const file of result.failed) {
78
- ctx.logger.warn(`dsh-tui-theme: could not install bundled theme "${file}"`);
76
+ ctx.logger.warn(`${PLUGIN_ID}: could not install bundled theme "${file}"`);
79
77
  }
80
78
  }
81
79
  // The /settings user layer (settings.yaml) overrides the cordis layer and
82
80
  // lands live through scope.watch; both override the hardcoded defaults.
83
81
  let effective = cordis;
84
- // Background follow applies only an existing cache. dsh-TUI v0.9.2 exposes
82
+ // Background follow applies only an existing cache. dsh-TUI exposes
85
83
  // no plugin terminal-query seam, so a theme plugin must not compete with
86
84
  // Ink's stdin reader or raw-mode lease. The /settings layer still determines
87
85
  // whether the cached result may control this startup.
@@ -89,24 +87,35 @@ export function apply(ctx, config = {}) {
89
87
  const dataDir = join(homeDir(), '.dsh-tui');
90
88
  const applyFollow = () => {
91
89
  runFollowSystem(dataDir, () => followActive === true, message => {
92
- ctx.logger.info(`dsh-tui-theme: ${message}`);
90
+ ctx.logger.info(`${PLUGIN_ID}: ${message}`);
93
91
  });
94
92
  };
95
93
  registerPinkSettings(ctx, cordis, doc => {
96
94
  effective = { ...cordis, ...doc };
97
- if (effective.followSystem !== followActive) {
98
- followActive = effective.followSystem;
99
- if (followActive) {
95
+ const follow = effective.followSystem === true;
96
+ if (followActive === undefined) {
97
+ // First document: align the baseline, not a switch. A value that only
98
+ // matches the default logs nothing; a user layer that starts enabled
99
+ // still applies the cache immediately.
100
+ followActive = follow;
101
+ if (follow)
102
+ applyFollow();
103
+ return;
104
+ }
105
+ if (followActive !== follow) {
106
+ followActive = follow;
107
+ if (follow) {
100
108
  applyFollow();
101
109
  }
102
110
  else {
103
- ctx.logger.info('dsh-tui-theme: follow: disabled, manual /theme choice preserved');
111
+ ctx.logger.info(`${PLUGIN_ID}: follow: disabled, manual /theme choice preserved`);
104
112
  }
105
113
  }
106
114
  });
107
- // Cached following needs the merged settings document; without the settings
108
- // seam the plugin keeps the user's existing theme choice and degrades to
109
- // static assets rather than applying a profile default before that document
110
- // could arrive.
115
+ // The follow decision is owned entirely by the /settings layer above: there
116
+ // is no timer or fallback path on hosts without a settings service — the
117
+ // plugin keeps the user's existing theme choice and degrades to static
118
+ // assets rather than applying a profile default before the merged document
119
+ // can arrive.
111
120
  startStatusLine(ctx, () => effective);
112
121
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The single plugin identifier shared by every surface that names this
3
+ * plugin to the host: the cordis plugin name, the tuiStatus contribution
4
+ * key, and the /settings namespace. These are distinct roles that happen
5
+ * to carry one value — all three import this constant rather than repeating
6
+ * the literal, so a rename stays consistent and the test contract (which
7
+ * re-derives the value from the package name) stays meaningful.
8
+ * @module dsh-tui-theme/pluginId
9
+ */
10
+ export declare const PLUGIN_ID = "dsh-tui-theme";
11
+ //# sourceMappingURL=pluginId.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pluginId.d.ts","sourceRoot":"","sources":["../../src/pluginId.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,eAAO,MAAM,SAAS,kBAAkB,CAAA"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The single plugin identifier shared by every surface that names this
3
+ * plugin to the host: the cordis plugin name, the tuiStatus contribution
4
+ * key, and the /settings namespace. These are distinct roles that happen
5
+ * to carry one value — all three import this constant rather than repeating
6
+ * the literal, so a rename stays consistent and the test contract (which
7
+ * re-derives the value from the package name) stays meaningful.
8
+ * @module dsh-tui-theme/pluginId
9
+ */
10
+ export const PLUGIN_ID = 'dsh-tui-theme';
@@ -12,7 +12,7 @@
12
12
  * Both services are consumed through `ctx.inject`, not apply-time `get`
13
13
  * probes: this row may start before the host's service rows, and the inject
14
14
  * fires whenever each service actually registers.
15
- * @module dsh-tui-pink-theme/settingsSection
15
+ * @module dsh-tui-theme/settingsSection
16
16
  */
17
17
  import type { Context } from '@deepseek-ai/cordis';
18
18
  import type { StatusOptions } from './statusLine.js';
@@ -1 +1 @@
1
- {"version":3,"file":"settingsSection.d.ts","sourceRoot":"","sources":["../../src/settingsSection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAGlD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAEpD,+EAA+E;AAC/E,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG;IAC5C,sEAAsE;IACtE,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,CAAA;AAcD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,aAAa,EACrB,KAAK,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,IAAI,GACpC,IAAI,CAoDN"}
1
+ {"version":3,"file":"settingsSection.d.ts","sourceRoot":"","sources":["../../src/settingsSection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAGlD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAGpD,+EAA+E;AAC/E,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG;IAC5C,sEAAsE;IACtE,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,mEAAmE;IACnE,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,CAAA;AAcD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,aAAa,EACrB,KAAK,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,IAAI,GACpC,IAAI,CAoDN"}
@@ -12,10 +12,11 @@
12
12
  * Both services are consumed through `ctx.inject`, not apply-time `get`
13
13
  * probes: this row may start before the host's service rows, and the inject
14
14
  * fires whenever each service actually registers.
15
- * @module dsh-tui-pink-theme/settingsSection
15
+ * @module dsh-tui-theme/settingsSection
16
16
  */
17
17
  import { settingsNamespace } from '@deepseek-ai/dsh-settings';
18
18
  import z from '@deepseek-ai/schemastery';
19
+ import { PLUGIN_ID } from './pluginId.js';
19
20
  /**
20
21
  * Register the settings namespace (mirror the resolved document to the
21
22
  * caller) and, separately, the /settings section for it. Each part waits for
@@ -31,7 +32,7 @@ export function registerPinkSettings(ctx, cordis, onDoc) {
31
32
  ctx.inject(['settings'], settingsCtx => {
32
33
  const settings = settingsCtx.settings;
33
34
  try {
34
- const scope = settings.register(settingsNamespace('dsh-tui-theme'), z.object({
35
+ const scope = settings.register(settingsNamespace(PLUGIN_ID), z.object({
35
36
  followSystem: z.boolean(),
36
37
  showGlyph: z.boolean(),
37
38
  showClock: z.boolean(),
@@ -58,7 +59,7 @@ export function registerPinkSettings(ctx, cordis, onDoc) {
58
59
  catch (error) {
59
60
  // A duplicate registration (hot reload race) or a stricter host must
60
61
  // not take the plugin — or the TUI — down.
61
- settingsCtx.logger.warn(`dsh-tui-pink-theme: settings namespace registration failed: ${String(error)}`);
62
+ settingsCtx.logger.warn(`dsh-tui-theme: settings namespace registration failed: ${String(error)}`);
62
63
  }
63
64
  });
64
65
  ctx.inject(['tuiSettingsSections'], sectionsCtx => {
@@ -71,24 +72,24 @@ export function registerPinkSettings(ctx, cordis, onDoc) {
71
72
  catch (error) {
72
73
  // A duplicate registration (hot reload race) or a stricter host must
73
74
  // not take the plugin — or the TUI — down.
74
- sectionsCtx.logger.warn(`dsh-tui-pink-theme: settings section registration failed: ${String(error)}`);
75
+ sectionsCtx.logger.warn(`dsh-tui-theme: settings section registration failed: ${String(error)}`);
75
76
  }
76
77
  });
77
78
  }
78
79
  /** The declarative /settings block (labels bilingual, zh via descriptions). */
79
80
  function sectionDefinition(cordis) {
80
81
  return {
81
- ns: 'dsh-tui-theme',
82
+ ns: PLUGIN_ID,
82
83
  title: 'pink-theme',
83
84
  descriptions: { zh: 'pink-theme' },
84
85
  fields: [
85
86
  {
86
87
  path: ['followSystem'],
87
- label: 'Use cached terminal background',
88
- descriptions: { zh: '使用已缓存的终端背景' },
89
- hint: 'Apply a previously saved terminal background result at startup. dsh-TUI 0.9.2 does not expose a safe plugin query, so this plugin does not refresh the cache.',
88
+ label: 'Apply saved terminal background',
89
+ descriptions: { zh: '应用上次保存的终端背景' },
90
+ hint: 'Apply a previously saved terminal background result at startup. dsh-TUI does not expose a safe plugin query, so this plugin does not refresh the cache.',
90
91
  hintDescriptions: {
91
- zh: '启动时应用此前保存的终端背景结果。dsh-TUI 0.9.2 未提供安全的插件查询接缝,因此本插件不会刷新该缓存。',
92
+ zh: '启动时应用此前保存的终端背景结果。dsh-TUI 未提供安全的插件查询接缝,因此本插件不会刷新该缓存。',
92
93
  },
93
94
  kind: 'boolean',
94
95
  format: (value) => String(value ?? cordis.followSystem),
@@ -10,7 +10,7 @@
10
10
  * pink theme is active (checked per render with the host's own theme
11
11
  * precedence, so a mid-session /theme switch takes effect on the next tick);
12
12
  * `statusScope: 'all-themes'` opts it into every other theme too.
13
- * @module dsh-tui-pink-theme/statusLine
13
+ * @module dsh-tui-theme/statusLine
14
14
  */
15
15
  import type { Context } from '@deepseek-ai/cordis';
16
16
  /** Which themes the blossom line renders under. */
@@ -1 +1 @@
1
- {"version":3,"file":"statusLine.d.ts","sourceRoot":"","sources":["../../src/statusLine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAOlD,mDAAmD;AACnD,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,YAAY,CAAA;AAEpD,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,sCAAsC;IACtC,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,+BAA+B;IAC/B,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,0DAA0D;IAC1D,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,iEAAiE;IACjE,WAAW,CAAC,EAAE,WAAW,CAAA;CAC1B;AAED,mCAAmC;AACnC,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC,aAAa,CAAC,CAAA;AAwCrD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,GAAG,IAAI,CA8DvF"}
1
+ {"version":3,"file":"statusLine.d.ts","sourceRoot":"","sources":["../../src/statusLine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAQlD,mDAAmD;AACnD,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,YAAY,CAAA;AAEpD,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,sCAAsC;IACtC,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,+BAA+B;IAC/B,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,0DAA0D;IAC1D,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,iEAAiE;IACjE,WAAW,CAAC,EAAE,WAAW,CAAA;CAC1B;AAED,mCAAmC;AACnC,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC,aAAa,CAAC,CAAA;AA+CrD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,GAAG,IAAI,CA8DvF"}
@@ -10,13 +10,16 @@
10
10
  * pink theme is active (checked per render with the host's own theme
11
11
  * precedence, so a mid-session /theme switch takes effect on the next tick);
12
12
  * `statusScope: 'all-themes'` opts it into every other theme too.
13
- * @module dsh-tui-pink-theme/statusLine
13
+ * @module dsh-tui-theme/statusLine
14
14
  */
15
15
  import { join } from 'node:path';
16
16
  import { homeDir } from './themeAssets.js';
17
17
  import { readThemePref } from './autoTheme.js';
18
+ import { PLUGIN_ID } from './pluginId.js';
18
19
  const GLYPH = '✿';
19
- const STATUS_KEY = 'dsh-tui-theme';
20
+ // The tuiStatus contribution key (same value as the settings namespace and
21
+ // the cordis plugin name — one literal would be three drift risks).
22
+ const STATUS_KEY = PLUGIN_ID;
20
23
  const CLOCK_TICK_MS = 15_000;
21
24
  /** The bundled themes this garnish belongs to. */
22
25
  const PINK_THEMES = new Set(['pink-night', 'pink-day', 'pink-ansi']);
@@ -25,6 +28,11 @@ const PINK_THEMES = new Set(['pink-night', 'pink-day', 'pink-ansi']);
25
28
  * then the persisted ~/.dsh-tui/theme.json pref. The unforced path (OSC 11
26
29
  * auto-detection) only ever resolves to a builtin palette, never a pink one,
27
30
  * so "no pref" means non-pink.
31
+ *
32
+ * This deliberately mirrors the host's ThemeProvider resolution chain
33
+ * (`components/design-system/ThemeProvider.tsx`, baseline dsh-TUI 0.9.3);
34
+ * keep the two in sync if the host adds a precedence layer. If the host ever
35
+ * exposes a theme-query seam for plugins, prefer that over this re-read.
28
36
  */
29
37
  function activeThemeName(dataDir) {
30
38
  const env = process.env.DSH_TUI_THEME;
@@ -3,15 +3,20 @@
3
3
  *
4
4
  * Copies the package's themes/*.json into ~/.dsh-tui/themes/ on boot. Only
5
5
  * files that do not exist yet are written — a user's edited or same-named
6
- * theme file is never overwritten. Every failure is contained per file: a
7
- * theme garnish must never break the TUI's boot.
8
- * @module dsh-tui-pink-theme/themeAssets
6
+ * theme file is never overwritten. The one exception is a target that no
7
+ * longer parses as JSON (a torn write from an interrupted installation):
8
+ * that file is backed up under a .corrupt-<timestamp> name and replaced, so
9
+ * a crash can never shadow a bundled theme forever. Every failure is
10
+ * contained per file: a theme garnish must never break the TUI's boot.
11
+ * @module dsh-tui-theme/themeAssets
9
12
  */
10
13
  export interface ThemeInstallResult {
11
14
  /** Files newly written into the target directory. */
12
15
  readonly installed: readonly string[];
13
16
  /** Files already present in the target directory (left untouched). */
14
17
  readonly skipped: readonly string[];
18
+ /** Corrupt targets backed up and reinstalled (self-heal). */
19
+ readonly repaired: readonly string[];
15
20
  /** Files that could not be installed (per-file failures). */
16
21
  readonly failed: readonly string[];
17
22
  }
@@ -1 +1 @@
1
- {"version":3,"file":"themeAssets.d.ts","sourceRoot":"","sources":["../../src/themeAssets.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,MAAM,WAAW,kBAAkB;IACjC,qDAAqD;IACrD,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;IACrC,sEAAsE;IACtE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CACnC;AAID,wBAAgB,OAAO,IAAI,MAAM,CAEhC;AAED,2EAA2E;AAC3E,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED,2DAA2D;AAC3D,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,kBAAkB,CA+BpB"}
1
+ {"version":3,"file":"themeAssets.d.ts","sourceRoot":"","sources":["../../src/themeAssets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAOH,MAAM,WAAW,kBAAkB;IACjC,qDAAqD;IACrD,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;IACrC,sEAAsE;IACtE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,6DAA6D;IAC7D,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CACnC;AAID,wBAAgB,OAAO,IAAI,MAAM,CAEhC;AAED,2EAA2E;AAC3E,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED,2DAA2D;AAC3D,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,kBAAkB,CAiCpB"}
@@ -3,11 +3,14 @@
3
3
  *
4
4
  * Copies the package's themes/*.json into ~/.dsh-tui/themes/ on boot. Only
5
5
  * files that do not exist yet are written — a user's edited or same-named
6
- * theme file is never overwritten. Every failure is contained per file: a
7
- * theme garnish must never break the TUI's boot.
8
- * @module dsh-tui-pink-theme/themeAssets
6
+ * theme file is never overwritten. The one exception is a target that no
7
+ * longer parses as JSON (a torn write from an interrupted installation):
8
+ * that file is backed up under a .corrupt-<timestamp> name and replaced, so
9
+ * a crash can never shadow a bundled theme forever. Every failure is
10
+ * contained per file: a theme garnish must never break the TUI's boot.
11
+ * @module dsh-tui-theme/themeAssets
9
12
  */
10
- import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
13
+ import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
11
14
  import { homedir } from 'node:os';
12
15
  import { dirname, join } from 'node:path';
13
16
  import { fileURLToPath } from 'node:url';
@@ -33,13 +36,14 @@ export function themesTargetDir() {
33
36
  export function installBundledThemes(targetDir = themesTargetDir(), sourceDir = bundledThemesDir()) {
34
37
  const installed = [];
35
38
  const skipped = [];
39
+ const repaired = [];
36
40
  const failed = [];
37
41
  let files;
38
42
  try {
39
43
  files = readdirSync(sourceDir).filter(entry => entry.toLowerCase().endsWith('.json'));
40
44
  }
41
45
  catch {
42
- return { installed, skipped, failed: [sourceDir] };
46
+ return { installed, skipped, repaired, failed: [sourceDir] };
43
47
  }
44
48
  for (const file of files) {
45
49
  const target = join(targetDir, file);
@@ -53,7 +57,10 @@ export function installBundledThemes(targetDir = themesTargetDir(), sourceDir =
53
57
  }
54
58
  catch (error) {
55
59
  if (error.code === 'EEXIST') {
56
- skipped.push(file);
60
+ if (healCorruptTarget(target, text))
61
+ repaired.push(file);
62
+ else
63
+ skipped.push(file);
57
64
  }
58
65
  else {
59
66
  failed.push(file);
@@ -64,5 +71,38 @@ export function installBundledThemes(targetDir = themesTargetDir(), sourceDir =
64
71
  failed.push(file);
65
72
  }
66
73
  }
67
- return { installed, skipped, failed };
74
+ return { installed, skipped, repaired, failed };
75
+ }
76
+ /**
77
+ * Self-heal an existing target that fails to parse as JSON — the leftover of
78
+ * a torn write from an interrupted installation. The damaged file is kept as
79
+ * <target>.corrupt-<timestamp> and the bundled copy installed fresh. Returns
80
+ * false (leave untouched) when the target is valid JSON (a user file the
81
+ * never-overwrite rule protects), unreadable (unreadable is not proven
82
+ * corrupt), or when the backup/replace itself fails (degrade to the plain
83
+ * silent skip).
84
+ */
85
+ function healCorruptTarget(target, text) {
86
+ let existing;
87
+ try {
88
+ existing = readFileSync(target, 'utf8');
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ try {
94
+ JSON.parse(existing);
95
+ return false;
96
+ }
97
+ catch {
98
+ // Proven corrupt: fall through to backup and reinstall.
99
+ }
100
+ try {
101
+ renameSync(target, `${target}.corrupt-${Date.now()}`);
102
+ writeFileSync(target, text, { flag: 'wx' });
103
+ return true;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
68
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tui-theme",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Sakura-pink themes for dsh-TUI with optional cached background follow (pink-day/pink-night), a blossom status line, and a /settings section. No shortcuts, no commands.",
5
5
  "type": "module",
6
6
  "main": "lib/types/index.js",
@@ -25,9 +25,10 @@
25
25
  },
26
26
  "scripts": {
27
27
  "build": "tsc -p tsconfig.json",
28
+ "preverify": "npm run build",
28
29
  "verify": "node scripts/verify.mjs",
29
30
  "verify:package": "node scripts/verify-package.mjs",
30
- "verify:host": "node scripts/headless-order-test.mjs && node --import tsx/esm scripts/validate-themes-against-host.mjs",
31
+ "verify:host": "npm run build && node scripts/headless-order-test.mjs && node --import tsx/esm scripts/validate-themes-against-host.mjs",
31
32
  "release:check": "npm run build && npm run verify && npm run verify:package",
32
33
  "prepack": "npm run release:check"
33
34
  },
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The settings contract this plugin publishes to the host's /settings form:
3
+ * the namespace, every field's path + kind, and the select options of the
4
+ * status scope. Both verification scripts assert against this single copy so
5
+ * a field addition or reorder only needs one edit here — the assertions are
6
+ * order-insensitive, so reordering the fields in sectionDefinition() is not
7
+ * an error by itself.
8
+ *
9
+ * The namespace is derived from the package name, mirroring PLUGIN_ID in
10
+ * src/pluginId.ts: if the package and its registration id ever drift apart,
11
+ * the headless order test fails on the namespace comparison instead of
12
+ * silently testing the wrong section.
13
+ */
14
+ import { readFileSync } from 'node:fs'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ export const SETTINGS_NAMESPACE = JSON.parse(
18
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
19
+ ).name
20
+
21
+ /** [path, kind] pairs; compare as a set, never positionally. */
22
+ export const SETTINGS_FIELDS = [
23
+ [['followSystem'], 'boolean'],
24
+ [['showGlyph'], 'boolean'],
25
+ [['showClock'], 'boolean'],
26
+ [['showTurns'], 'boolean'],
27
+ [['statusScope'], 'select'],
28
+ ]
29
+
30
+ /** The values the statusScope select must expose, looked up by field path. */
31
+ export const STATUS_SCOPE_FIELD_PATH = ['statusScope']
32
+ export const STATUS_SCOPE_OPTIONS = ['pink-only', 'all-themes']
33
+
34
+ /** Assert a section object (ns + fields) matches the contract. */
35
+ export function assertSettingsContract(assert, section) {
36
+ assert.equal(section.ns, SETTINGS_NAMESPACE, 'settings namespace must match')
37
+ const actual = section.fields.map(field => [field.path, field.kind])
38
+ const expected = SETTINGS_FIELDS.map(([path, kind]) => `${JSON.stringify(path)}:${kind}`)
39
+ assert.deepEqual(
40
+ actual.map(([path, kind]) => `${JSON.stringify(path)}:${kind}`).sort(),
41
+ [...expected].sort(),
42
+ 'settings fields must remain compatible with the host form contract',
43
+ )
44
+ const scopeField = section.fields.find(
45
+ field => JSON.stringify(field.path) === JSON.stringify(STATUS_SCOPE_FIELD_PATH),
46
+ )
47
+ assert.ok(scopeField, 'statusScope field must exist (looked up by path, not position)')
48
+ assert.deepEqual(
49
+ scopeField.options?.map(option => option.value),
50
+ STATUS_SCOPE_OPTIONS,
51
+ 'status scope must expose both supported select values',
52
+ )
53
+ }
@@ -3,6 +3,10 @@
3
3
  * plugin before the extension services to exercise late service injection.
4
4
  *
5
5
  * DSH_TUI_ADAPTER_DIR must point at dsh-TUI's lib/types/dsh-adapter directory.
6
+ * Script-side floor: the adapter must be a built dsh-TUI >= 0.9.0 — the
7
+ * settings-sections module and its getHostSettingsSections probe landed there.
8
+ * Set DSH_TUI_EXPECTED_VERSION only when an explicit release baseline needs
9
+ * to be pinned; ordinary development verifies the supplied host as-is.
6
10
  */
7
11
  import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
8
12
  import { tmpdir } from 'node:os'
@@ -10,6 +14,13 @@ import { join } from 'node:path'
10
14
  import { fileURLToPath, pathToFileURL } from 'node:url'
11
15
  import { createRequire } from 'node:module'
12
16
  import assert from 'node:assert/strict'
17
+ import { assertSettingsContract, SETTINGS_NAMESPACE } from './expected-settings-contract.mjs'
18
+
19
+ // The tuiStatus contribution key and the effect-ledger resource id. Deliberately
20
+ // a separate constant from SETTINGS_NAMESPACE: they hold the same value today
21
+ // (see src/pluginId.ts), but this assertion pins that equality while the status
22
+ // snapshot / ledger filters below must never silently track the namespace.
23
+ const STATUS_CONTRIBUTION_KEY = SETTINGS_NAMESPACE
13
24
 
14
25
  const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
15
26
  const adapter = process.env.DSH_TUI_ADAPTER_DIR
@@ -26,7 +37,13 @@ if (!existsSync(hostPackagePath)) {
26
37
  throw new Error(`dsh-TUI package metadata not found above adapter at ${adapter}`)
27
38
  }
28
39
  const hostPackage = JSON.parse(readFileSync(hostPackagePath, 'utf8'))
29
- assert.equal(hostPackage.version, '0.9.2', `expected dsh-TUI adapter 0.9.2, received ${hostPackage.version}`)
40
+ assert.match(hostPackage.version, /^\d+\.\d+\.\d+(?:[-+].+)?$/u, 'host adapter must declare a version')
41
+ const expectedVersion = process.env.DSH_TUI_EXPECTED_VERSION
42
+ if (expectedVersion !== undefined && expectedVersion !== '') {
43
+ assert.equal(hostPackage.version, expectedVersion, `expected dsh-TUI adapter ${expectedVersion}, received ${hostPackage.version}`)
44
+ } else {
45
+ console.log(`* host adapter ${hostPackage.version} (no explicit version pin)`)
46
+ }
30
47
 
31
48
  const sandbox = mkdtempSync(join(tmpdir(), 'pink-order-'))
32
49
  process.env.USERPROFILE = sandbox
@@ -36,32 +53,80 @@ const dataDir = join(sandbox, '.dsh-tui')
36
53
  mkdirSync(dataDir, { recursive: true })
37
54
  writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
38
55
 
56
+ // Guard the script-side floor explicitly: settings-sections.js first shipped
57
+ // in dsh-TUI 0.9.0, so a bare ERR_MODULE_NOT_FOUND from a dynamic import
58
+ // would hide the real reason a host tree is too old for this test.
59
+ const settingsSectionsPath = join(adapter, 'settings-sections.js')
60
+ if (!existsSync(settingsSectionsPath)) {
61
+ throw new Error(
62
+ `dsh-TUI adapter at ${adapter} has no settings-sections.js; this integration test needs a host >= 0.9.0`,
63
+ )
64
+ }
65
+
39
66
  const req = createRequire(join(adapter, 'extensions.js'))
40
67
  const { Context } = await import(pathToFileURL(req.resolve('@deepseek-ai/cordis')).href)
41
68
  const extensions = await import(pathToFileURL(join(adapter, 'extensions.js')).href)
42
69
  const ledgerModule = await import(pathToFileURL(join(adapter, 'effect-ledger.js')).href)
43
70
  const statusModule = await import(pathToFileURL(join(adapter, 'status.js')).href)
71
+ const settingsSectionsModule = await import(pathToFileURL(settingsSectionsPath).href)
44
72
  const pink = await import(pathToFileURL(join(pluginRoot, 'lib', 'types', 'index.js')).href)
45
73
 
46
74
  const app = new Context()
47
75
  await app.plugin(ledgerModule.default)
48
76
  await app.plugin(pink)
49
77
  await app.plugin(extensions.default ?? extensions)
50
- await new Promise(resolve => setTimeout(resolve, 300))
78
+ await app.plugin(settingsSectionsModule.default ?? settingsSectionsModule)
51
79
 
52
- const ledger = join(dataDir, 'effect-ledger.jsonl')
53
- const pinkBinds = existsSync(ledger)
54
- ? readFileSync(ledger, 'utf8').trim().split('\n')
55
- .filter(Boolean)
56
- .map(line => JSON.parse(line))
57
- .filter(entry => entry.resource?.id === 'dsh-tui-theme')
58
- : []
59
- const runtime = app.get('tuiStatus')
60
- const snapshot = statusModule.getHostStatusStore(runtime)?.getSnapshot()
80
+ // The late injections resolve asynchronously; poll for every observable
81
+ // outcome instead of sleeping a fixed wall-clock delay.
82
+ const READY_TIMEOUT_MS = 5_000
83
+ const POLL_INTERVAL_MS = 25
84
+ const collectBinds = () => {
85
+ const ledger = join(dataDir, 'effect-ledger.jsonl')
86
+ return existsSync(ledger)
87
+ ? readFileSync(ledger, 'utf8').trim().split('\n')
88
+ .filter(Boolean)
89
+ .map(line => JSON.parse(line))
90
+ .filter(entry => entry.resource?.id === STATUS_CONTRIBUTION_KEY)
91
+ : []
92
+ }
93
+ const readState = () => {
94
+ const runtime = app.get('tuiStatus')
95
+ const settingsRuntime = app.get('tuiSettingsSections')
96
+ const settingsHost = settingsSectionsModule.getHostSettingsSections(settingsRuntime)
97
+ return {
98
+ pinkBinds: collectBinds(),
99
+ snapshot: statusModule.getHostStatusStore(runtime)?.getSnapshot(),
100
+ settingsSection: settingsHost?.list().find(section => section.ns === SETTINGS_NAMESPACE),
101
+ }
102
+ }
61
103
 
62
- assert.ok(pinkBinds.length > 0, 'plugin must bind through the late status service')
104
+ let state = readState()
105
+ const deadline = Date.now() + READY_TIMEOUT_MS
106
+ while (
107
+ (state.pinkBinds.length === 0 ||
108
+ !state.snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY) ||
109
+ state.settingsSection === undefined) &&
110
+ Date.now() < deadline
111
+ ) {
112
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
113
+ state = readState()
114
+ }
115
+
116
+ const { pinkBinds, snapshot, settingsSection } = state
117
+ assert.equal(
118
+ STATUS_CONTRIBUTION_KEY,
119
+ SETTINGS_NAMESPACE,
120
+ 'the status contribution key must stay equal to the settings namespace (src/pluginId.ts)',
121
+ )
122
+ assert.ok(pinkBinds.length > 0, `plugin must bind through the late status service within ${READY_TIMEOUT_MS}ms`)
123
+ assert.ok(
124
+ snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY),
125
+ `status store must contain the plugin contribution within ${READY_TIMEOUT_MS}ms`,
126
+ )
63
127
  assert.ok(
64
- snapshot?.some?.(entry => entry.key === 'dsh-tui-theme'),
65
- 'status store must contain the plugin contribution',
128
+ settingsSection,
129
+ `plugin must register its /settings section through the late settings service within ${READY_TIMEOUT_MS}ms`,
66
130
  )
67
- console.log(`✓ headless order: ${pinkBinds.length} ledger bind(s), visible status contribution`)
131
+ assertSettingsContract(assert, settingsSection)
132
+ console.log(`OK headless order: ${pinkBinds.length} ledger bind(s), status contribution, settings section`)
@@ -1,14 +1,18 @@
1
1
  /**
2
- * Validate bundled themes against the real dsh-TUI source implementation.
2
+ * Validate bundled themes against matching dsh-TUI source and runtime modules.
3
3
  * DSH_TUI_SOURCE_ROOT must point at a dsh-TUI source checkout.
4
+ * DSH_TUI_ADAPTER_DIR must point at the matching lib/types/dsh-adapter directory.
5
+ * Set DSH_TUI_EXPECTED_VERSION only when an explicit release baseline needs
6
+ * to be pinned; ordinary development verifies the supplied host as-is.
4
7
  *
5
- * Run with: npx -y tsx scripts/validate-themes-against-host.mjs
8
+ * Run with: node --import tsx/esm scripts/validate-themes-against-host.mjs
6
9
  */
7
10
  import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs'
8
11
  import { tmpdir } from 'node:os'
9
12
  import { join, resolve } from 'node:path'
10
13
  import { fileURLToPath, pathToFileURL } from 'node:url'
11
14
  import assert from 'node:assert/strict'
15
+ import ts from 'typescript'
12
16
 
13
17
  const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
14
18
  const sourceRoot = process.env.DSH_TUI_SOURCE_ROOT
@@ -28,21 +32,80 @@ if (!existsSync(hostPackagePath)) {
28
32
  throw new Error(`dsh-TUI package metadata not found at ${hostPackagePath}`)
29
33
  }
30
34
  const hostPackage = JSON.parse(readFileSync(hostPackagePath, 'utf8'))
31
- assert.equal(hostPackage.version, '0.9.2', `expected dsh-TUI source 0.9.2, received ${hostPackage.version}`)
35
+ assert.match(hostPackage.version, /^\d+\.\d+\.\d+(?:[-+].+)?$/u, 'host source must declare a version')
36
+
37
+ const adapter = process.env.DSH_TUI_ADAPTER_DIR
38
+ if (adapter === undefined || adapter === '') {
39
+ throw new Error('DSH_TUI_ADAPTER_DIR must point at dsh-TUI lib/types/dsh-adapter for this host theme validation.')
40
+ }
41
+ const adapterRoot = resolve(adapter)
42
+ const runtimeThemePath = join(adapterRoot, '..', 'theme.js')
43
+ const runtimeCustomThemePath = join(adapterRoot, '..', 'customTheme.js')
44
+ if (!existsSync(runtimeThemePath) || !existsSync(runtimeCustomThemePath)) {
45
+ throw new Error(`dsh-TUI compiled theme modules not found above adapter at ${adapterRoot}`)
46
+ }
47
+ const adapterPackagePath = join(adapterRoot, '..', '..', '..', 'package.json')
48
+ if (!existsSync(adapterPackagePath)) {
49
+ throw new Error(`dsh-TUI package metadata not found above adapter at ${adapterRoot}`)
50
+ }
51
+ const adapterPackage = JSON.parse(readFileSync(adapterPackagePath, 'utf8'))
52
+ assert.equal(
53
+ adapterPackage.version,
54
+ hostPackage.version,
55
+ `source ${hostPackage.version} and adapter ${adapterPackage.version} must be the same dsh-TUI version`,
56
+ )
57
+ const expectedVersion = process.env.DSH_TUI_EXPECTED_VERSION
58
+ if (expectedVersion !== undefined && expectedVersion !== '') {
59
+ assert.equal(hostPackage.version, expectedVersion, `expected dsh-TUI ${expectedVersion}, received ${hostPackage.version}`)
60
+ } else {
61
+ console.log(`* host source and adapter ${hostPackage.version} (no explicit version pin)`)
62
+ }
32
63
 
33
64
  const sandboxHome = mkdtempSync(join(tmpdir(), 'pink-theme-host-validate-'))
34
65
  process.env.USERPROFILE = sandboxHome
35
66
  process.env.HOME = sandboxHome
36
67
 
37
68
  const themesDir = join(pluginRoot, 'themes')
38
- const { parseCustomTheme, buildTheme } = await import(pathToFileURL(customThemePath).href)
69
+ const { parseCustomTheme, buildTheme } = await import(pathToFileURL(runtimeCustomThemePath).href)
39
70
  const { getTheme, isLightThemeActive, registerCustomThemeResolver } = await import(
40
- pathToFileURL(themePath).href
71
+ pathToFileURL(runtimeThemePath).href,
41
72
  )
42
73
 
43
- const allKeys = Object.keys(getTheme('dark'))
74
+ function readThemeKeysFromSource(path) {
75
+ const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true)
76
+ const declaration = source.statements.find(
77
+ statement => ts.isTypeAliasDeclaration(statement) && statement.name.text === 'Theme',
78
+ )
79
+ assert.ok(declaration && ts.isTypeLiteralNode(declaration.type), 'host Theme must remain a type literal')
80
+ const keys = declaration.type.members.flatMap(member => {
81
+ if (!ts.isPropertySignature(member) || member.name === undefined) return []
82
+ if (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) return [member.name.text]
83
+ return []
84
+ })
85
+ assert.ok(keys.length > 0, 'host Theme must declare at least one color key')
86
+ return keys
87
+ }
88
+
89
+ const allKeys = readThemeKeysFromSource(themePath)
90
+ assert.deepEqual(
91
+ [...Object.keys(getTheme('dark'))].sort(),
92
+ [...allKeys].sort(),
93
+ 'compiled theme keys must match the checked-out host source',
94
+ )
44
95
  assert.ok(allKeys.length >= 90, 'host Theme key count drifted; re-check coverage')
45
96
 
97
+ const settingsKeys = [
98
+ 'promptBorder',
99
+ 'selectionBg',
100
+ 'permission',
101
+ 'suggestion',
102
+ 'success',
103
+ 'inactive',
104
+ 'subtle',
105
+ 'warning',
106
+ 'error',
107
+ ]
108
+
46
109
  function parseColor(value) {
47
110
  if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
48
111
  const n = Number.parseInt(value.slice(1), 16)
@@ -86,6 +149,12 @@ for (const file of readdirSync(themesDir).filter(name => name.endsWith('.json'))
86
149
  assert.deepEqual(warnings, [], `${file} must produce zero host warnings`)
87
150
  const missing = allKeys.filter(key => key !== 'userMessageBackground' && !(key in spec.colors))
88
151
  assert.deepEqual(missing, [], `${file} must cover every Theme key`)
152
+ const missingSettingsKeys = settingsKeys.filter(key => !(key in spec.colors))
153
+ assert.deepEqual(
154
+ missingSettingsKeys,
155
+ [],
156
+ `${file} must cover every color used by the dsh-TUI settings cards and checkbox chips`,
157
+ )
89
158
 
90
159
  const theme = buildTheme(spec)
91
160
  built[spec.name] = theme
@@ -96,14 +165,29 @@ for (const [name, light] of Object.entries(expectedLight)) {
96
165
  assert.equal(isLightThemeActive(name), light, `${name} identity via resolver`)
97
166
  }
98
167
 
168
+ // Backgrounds: the /settings section renders inside the session screen, so
169
+ // unfocused rows sit on the theme's terminal background while a focused row
170
+ // gets selectionBg (screens/Settings.tsx CardRow). success/inactive are the
171
+ // checkbox chip colors there ([✓] vs [ ]).
99
172
  const cases = [
100
173
  ['pink-night', 'text', '#1E1E1E', 4.5],
101
174
  ['pink-night', 'claude', '#1E1E1E', 3.0],
102
175
  ['pink-night', 'inactive', '#1E1E1E', 2.5],
176
+ ['pink-night', 'success', '#1E1E1E', 4.5],
177
+ ['pink-night', 'success', '#55303E', 3.0],
178
+ ['pink-night', 'inactive', '#55303E', 3.0],
103
179
  ['pink-day', 'text', '#F6F3ED', 4.5],
104
180
  ['pink-day', 'claude', '#F6F3ED', 3.0],
105
181
  ['pink-day', 'inactive', '#F6F3ED', 2.5],
182
+ ['pink-day', 'success', '#F6F3ED', 3.0],
183
+ ['pink-day', 'success', '#F3D7E0', 2.5],
184
+ ['pink-day', 'inactive', '#F3D7E0', 2.5],
106
185
  ]
186
+ // pink-ansi is intentionally absent from the contrast cases: every one of its
187
+ // colors is an `ansi:` palette token with no pinned RGB value, so no numeric
188
+ // ratio is assertable here — the host renders whatever the user's terminal
189
+ // palette defines. Its settings keys are still covered by the per-theme
190
+ // key-coverage assertions above.
107
191
  for (const [theme, key, background, minimum] of cases) {
108
192
  const foregroundRgb = parseColor(built[theme][key])
109
193
  const backgroundRgb = parseColor(background)
@@ -112,4 +196,4 @@ for (const [theme, key, background, minimum] of cases) {
112
196
  assert.ok(ratio >= minimum, `${theme}.${key} contrast ${ratio.toFixed(2)} >= ${minimum}`)
113
197
  }
114
198
 
115
- console.log(`✓ host theme validation: ${Object.keys(built).length} themes, ${allKeys.length - 1} keys each`)
199
+ console.log(`OK host theme validation: ${Object.keys(built).length} themes, ${allKeys.length - 1} keys each, settings colors covered`)
@@ -32,6 +32,8 @@ for (const required of [
32
32
  'lib/types/index.d.ts',
33
33
  'lib/types/autoTheme.js',
34
34
  'lib/types/autoTheme.d.ts',
35
+ 'lib/types/pluginId.js',
36
+ 'lib/types/pluginId.d.ts',
35
37
  'lib/types/settingsSection.js',
36
38
  'lib/types/settingsSection.d.ts',
37
39
  'lib/types/statusLine.js',
@@ -48,6 +50,7 @@ for (const required of [
48
50
  'scripts/verify-package.mjs',
49
51
  'scripts/headless-order-test.mjs',
50
52
  'scripts/validate-themes-against-host.mjs',
53
+ 'scripts/expected-settings-contract.mjs',
51
54
  ]) {
52
55
  assert.ok(files.has(required), `published package must include ${required}`)
53
56
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Hermetic verification for dsh-tui-pink-theme (no TTY, no real HOME).
2
+ * Hermetic verification for dsh-tui-theme (no TTY, no real HOME).
3
3
  *
4
4
  * Points HOME/USERPROFILE at a throwaway sandbox BEFORE importing anything
5
5
  * (the same technique the host's scripts/verify-themes.mjs uses), then:
@@ -22,6 +22,7 @@ import { join } from 'node:path'
22
22
  import { fileURLToPath } from 'node:url'
23
23
  import { createRequire, syncBuiltinESMExports } from 'node:module'
24
24
  import assert from 'node:assert/strict'
25
+ import { assertSettingsContract } from './expected-settings-contract.mjs'
25
26
 
26
27
  const sandboxHome = mkdtempSync(join(tmpdir(), 'pink-theme-verify-'))
27
28
  const originalThemeOverride = process.env.DSH_TUI_THEME
@@ -58,8 +59,8 @@ assert.equal(name, 'dsh-tui-theme')
58
59
 
59
60
  /** A stub Cordis-like context; every seam optional and recorded. */
60
61
  function makeStubCtx({ status, sections, settingsService } = {}) {
61
- const record = { handlers: new Map(), disposers: [], statusCalls: [], sectionsCalls: [], registerCalls: [], watchers: [], warnings: [] }
62
- const logger = { info: () => {}, warn: msg => record.warnings.push(String(msg)), error: () => {} }
62
+ const record = { handlers: new Map(), disposers: [], statusCalls: [], sectionsCalls: [], registerCalls: [], watchers: [], warnings: [], infos: [] }
63
+ const logger = { info: msg => record.infos.push(String(msg)), warn: msg => record.warnings.push(String(msg)), error: () => {} }
63
64
  const base = {
64
65
  logger,
65
66
  get(serviceName) {
@@ -157,11 +158,11 @@ const emit = (record, event, ...args) => {
157
158
  const latest = statusCalls.at(-1)[1]
158
159
  assert.match(latest, /^✿ · \d{2}:\d{2} · 2✦$/)
159
160
 
160
- // Settings namespace registered and the section declared.
161
+ // Settings namespace registered and the section declaration remains within
162
+ // the shared host form contract.
161
163
  assert.equal(settingsRecord.registerCalls.length, 1)
162
164
  assert.equal(sectionsCalls.length, 1)
163
- assert.equal(sectionsCalls[0].ns, 'dsh-tui-theme')
164
- assert.equal(sectionsCalls[0].fields.length, 5)
165
+ assertSettingsContract(assert, sectionsCalls[0])
165
166
 
166
167
  // A committed /settings edit lands live on the next render.
167
168
  for (const watcher of settingsRecord.watchers) {
@@ -179,6 +180,7 @@ const emit = (record, event, ...args) => {
179
180
  {
180
181
  const again = installBundledThemes()
181
182
  assert.deepEqual(again.installed, [])
183
+ assert.deepEqual(again.repaired, [])
182
184
  assert.equal(again.skipped.length, 3)
183
185
 
184
186
  // A user-edited same-named file must survive reinstallation.
@@ -380,6 +382,97 @@ const emit = (record, event, ...args) => {
380
382
  console.log('✓ filesystem commits: failed atomic writes preserve JSON; competing theme creation skips')
381
383
  }
382
384
 
385
+ // ── 12a. torn installation: a corrupt target is backed up and reinstalled ───
386
+ {
387
+ const targetDir = join(sandboxHome, 'heal-target')
388
+ const sourceDir = join(pluginRoot, 'themes')
389
+ mkdirSync(targetDir, { recursive: true })
390
+
391
+ // A valid user file stays untouched — the never-overwrite rule wins.
392
+ writeFileSync(join(targetDir, 'pink-night.json'), '{ "user": true }')
393
+ // A torn write (crash mid-install) leaves invalid JSON behind.
394
+ writeFileSync(join(targetDir, 'pink-day.json'), '{ "name": "pink-day", "colors": {')
395
+ const heal = installBundledThemes(targetDir, sourceDir)
396
+ assert.deepEqual(heal.repaired, ['pink-day.json'], 'the corrupt target is reported as repaired')
397
+ assert.equal(heal.skipped.includes('pink-night.json'), true)
398
+ assert.equal(heal.failed.length, 0)
399
+ assert.equal(
400
+ JSON.parse(readFileSync(join(targetDir, 'pink-night.json'), 'utf8')).user,
401
+ true,
402
+ 'valid user file untouched',
403
+ )
404
+ const reinstalled = JSON.parse(readFileSync(join(targetDir, 'pink-day.json'), 'utf8'))
405
+ assert.equal(reinstalled.name, 'pink-day')
406
+ const backups = readdirSync(targetDir).filter(entry =>
407
+ entry.startsWith('pink-day.json.corrupt-'),
408
+ )
409
+ assert.equal(backups.length, 1, 'the damaged file is preserved as a timestamped backup')
410
+ assert.equal(readFileSync(join(targetDir, backups[0]), 'utf8'), '{ "name": "pink-day", "colors": {')
411
+
412
+ // Next boot: everything parses, so no churn.
413
+ const steady = installBundledThemes(targetDir, sourceDir)
414
+ assert.deepEqual(steady.repaired, [])
415
+ assert.deepEqual(steady.installed, [])
416
+ assert.equal(steady.skipped.length, 3)
417
+
418
+ // A target the process cannot even read is not proven corrupt — keep skipping.
419
+ const unreadable = join(targetDir, 'pink-ansi.json')
420
+ const originalRead = builtinFs.readFileSync
421
+ try {
422
+ builtinFs.readFileSync = (path, ...rest) => {
423
+ if (String(path) === unreadable) throw new Error('EBUSY: locked')
424
+ return originalRead(path, ...rest)
425
+ }
426
+ syncBuiltinESMExports()
427
+ const blocked = installBundledThemes(targetDir, sourceDir)
428
+ assert.deepEqual(blocked.repaired, [])
429
+ assert.equal(blocked.skipped.includes('pink-ansi.json'), true)
430
+ } finally {
431
+ builtinFs.readFileSync = originalRead
432
+ syncBuiltinESMExports()
433
+ }
434
+ console.log('✓ torn installation: corrupt target backed up and reinstalled; user files and unreadable targets untouched')
435
+ }
436
+
437
+ // ── 12b. follow logging: the first settings doc is a baseline, not a flip ──
438
+ {
439
+ // Default user layer (empty doc): no spurious "follow: disabled" line.
440
+ const settingsRecord = { registerCalls: [], watchers: [] }
441
+ const { ctx, record } = makeStubCtx({ settingsService: fakeSettingsService(settingsRecord, {}) })
442
+ apply(ctx)
443
+ assert.equal(
444
+ record.infos.some(message => message.includes('follow: disabled')),
445
+ false,
446
+ 'a baseline doc matching the default must not log a disabled flip',
447
+ )
448
+
449
+ // User layer that starts enabled: the cache applies during the baseline.
450
+ const dataDir = join(sandboxHome, '.dsh-tui')
451
+ writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }))
452
+ const enabledRecord = { registerCalls: [], watchers: [] }
453
+ const { ctx: enabledCtx, record: enabledInfo } = makeStubCtx({
454
+ status: fakeStatus([]),
455
+ sections: fakeSections([]),
456
+ settingsService: fakeSettingsService(enabledRecord, { followSystem: true }),
457
+ })
458
+ apply(enabledCtx)
459
+ assert.equal(readThemePref(dataDir), 'pink-day', 'an enabled baseline applies the cached background')
460
+ assert.equal(
461
+ enabledInfo.infos.some(message => message.includes('follow: disabled')),
462
+ false,
463
+ )
464
+
465
+ // A real toggle keeps its log.
466
+ for (const watcher of enabledRecord.watchers) watcher({ followSystem: false })
467
+ assert.equal(
468
+ enabledInfo.infos.filter(message => message.includes('follow: disabled')).length,
469
+ 1,
470
+ 'disabling follow after the baseline logs exactly once',
471
+ )
472
+ assert.equal(readThemePref(dataDir), 'pink-day', 'the manual choice stays intact')
473
+ console.log('✓ follow logging: baseline docs stay quiet, real toggles still log')
474
+ }
475
+
383
476
  // ── 12. status injection: session handlers die with tuiStatus activation ────
384
477
  {
385
478
  const outerHandlers = new Map()