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 +14 -15
- package/docs/screenshots/settings.png +0 -0
- package/lib/types/autoTheme.d.ts +1 -1
- package/lib/types/autoTheme.js +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +28 -19
- package/lib/types/pluginId.d.ts +11 -0
- package/lib/types/pluginId.d.ts.map +1 -0
- package/lib/types/pluginId.js +10 -0
- package/lib/types/settingsSection.d.ts +1 -1
- package/lib/types/settingsSection.d.ts.map +1 -1
- package/lib/types/settingsSection.js +10 -9
- package/lib/types/statusLine.d.ts +1 -1
- package/lib/types/statusLine.d.ts.map +1 -1
- package/lib/types/statusLine.js +10 -2
- package/lib/types/themeAssets.d.ts +8 -3
- package/lib/types/themeAssets.d.ts.map +1 -1
- package/lib/types/themeAssets.js +47 -7
- package/package.json +3 -2
- package/scripts/expected-settings-contract.mjs +53 -0
- package/scripts/headless-order-test.mjs +80 -15
- package/scripts/validate-themes-against-host.mjs +91 -7
- package/scripts/verify-package.mjs +3 -0
- package/scripts/verify.mjs +99 -6
package/README.md
CHANGED
|
@@ -15,36 +15,34 @@
|
|
|
15
15
|
|
|
16
16
|
| 主题 | 基底 | 风格 |
|
|
17
17
|
| --- | --- | --- |
|
|
18
|
-
| `pink-night` 夜樱 | dark |
|
|
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
|
-
|
|
26
|
+
主题主界面截图实测于 dsh-tui 0.9.2;0.9.3 的 `/settings` 卡片界面见下图:
|
|
29
27
|
|
|
30
28
|
| 昼樱 `pink-day` | 夜樱 `pink-night` |
|
|
31
29
|
| :---: | :---: |
|
|
32
30
|
|  |  |
|
|
33
31
|
|
|
34
|
-
`/settings` 里的 pink-theme
|
|
32
|
+
`/settings` 里的 pink-theme 区块(终端背景 / 花符 / 时钟 / 轮数 / 状态行展示,保存即时生效):
|
|
35
33
|
|
|
36
34
|

|
|
37
35
|
|
|
38
|
-
>
|
|
36
|
+
> 图中 ❯ 提示符与链接是宿主硬编码的,见下文[宿主限制](#受宿主限制目前无法定制的部分)。
|
|
39
37
|
|
|
40
38
|
## 缓存背景跟随
|
|
41
39
|
|
|
42
|
-
`dsh-TUI
|
|
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`
|
|
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
|
|
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
|
|
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` |
|
|
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.
|
|
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`
|
|
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.
|
|
144
|
+
- **dsh-TUI 版本下限:0.8.8**(状态行与设置面板;0.9.3 实测)。更旧的宿主缺 `dsh-tui-extensions` 扩展面时,插件自动降级为“仅安装三套主题”,不报错。
|
|
146
145
|
- Node `^22.19 || >=24`,纯 ESM,MIT。
|
|
Binary file
|
package/lib/types/autoTheme.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cached terminal-background follow for the pink theme pair.
|
|
3
3
|
*
|
|
4
|
-
* dsh-TUI
|
|
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
|
package/lib/types/autoTheme.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cached terminal-background follow for the pink theme pair.
|
|
3
3
|
*
|
|
4
|
-
* dsh-TUI
|
|
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
|
package/lib/types/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/lib/types/index.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
90
|
+
ctx.logger.info(`${PLUGIN_ID}: ${message}`);
|
|
93
91
|
});
|
|
94
92
|
};
|
|
95
93
|
registerPinkSettings(ctx, cordis, doc => {
|
|
96
94
|
effective = { ...cordis, ...doc };
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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(
|
|
111
|
+
ctx.logger.info(`${PLUGIN_ID}: follow: disabled, manual /theme choice preserved`);
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
114
|
});
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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-
|
|
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;
|
|
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-
|
|
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(
|
|
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-
|
|
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-
|
|
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:
|
|
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: '
|
|
88
|
-
descriptions: { zh: '
|
|
89
|
-
hint: 'Apply a previously saved terminal background result at startup. dsh-TUI
|
|
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
|
|
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-
|
|
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;
|
|
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"}
|
package/lib/types/statusLine.js
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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"}
|
package/lib/types/themeAssets.js
CHANGED
|
@@ -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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
78
|
+
await app.plugin(settingsSectionsModule.default ?? settingsSectionsModule)
|
|
51
79
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
128
|
+
settingsSection,
|
|
129
|
+
`plugin must register its /settings section through the late settings service within ${READY_TIMEOUT_MS}ms`,
|
|
66
130
|
)
|
|
67
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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(
|
|
69
|
+
const { parseCustomTheme, buildTheme } = await import(pathToFileURL(runtimeCustomThemePath).href)
|
|
39
70
|
const { getTheme, isLightThemeActive, registerCustomThemeResolver } = await import(
|
|
40
|
-
pathToFileURL(
|
|
71
|
+
pathToFileURL(runtimeThemePath).href,
|
|
41
72
|
)
|
|
42
73
|
|
|
43
|
-
|
|
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(
|
|
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
|
}
|
package/scripts/verify.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hermetic verification for dsh-tui-
|
|
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:
|
|
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
|
|
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
|
|
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()
|