dsh-tui-theme 0.3.1 → 0.4.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.
@@ -4,8 +4,8 @@
4
4
  * 个性化全部走既有接缝,不注册快捷键、不注册命令、不拦截任何输入:
5
5
  * - 接缝四(主题):内置三套粉色 JSON(pink-night / pink-day / pink-ansi),
6
6
  * 启动时复制进 ~/.dsh-tui/themes/(仅缺失时,绝不覆盖用户已有文件);
7
- * - 自动跟随:检测终端/系统背景色(OSC 11,与宿主同阈值),在昼樱/夜樱
8
- * 间自动切换——pink 版的 auto
7
+ * - 缓存背景跟随:安全应用已有的终端背景缓存,在昼樱/夜樱间切换;
8
+ * dsh-TUI 未提供插件终端查询接缝时,不直接读写 stdin、raw mode 或 OSC 11
9
9
  * - 接缝十一(状态行):输入框上方一行小装饰(✿ · 时钟 · 本轮轮数);
10
10
  * - 接缝六(设置区块):/settings 里一个可编辑面板,即时生效。
11
11
  *
@@ -81,17 +81,14 @@ export function apply(ctx, config = {}) {
81
81
  // The /settings user layer (settings.yaml) overrides the cordis layer and
82
82
  // lands live through scope.watch; both override the hardcoded defaults.
83
83
  let effective = cordis;
84
- // Background follow honors the MERGED knob (cordis layer overlaid by the
85
- // /settings user layer), so the decision cannot be taken synchronously at
86
- // apply(): the user layer is only readable once the settings service
87
- // answers. The settings callback still fires before the host's React tree
88
- // mounts (the host gates its own mount on the same service, probed
89
- // against 0.9.0), so the pref write decides this boot exactly like a sync
90
- // write would, and toggling followSystem in /settings re-decides live.
84
+ // Background follow applies only an existing cache. dsh-TUI v0.9.2 exposes
85
+ // no plugin terminal-query seam, so a theme plugin must not compete with
86
+ // Ink's stdin reader or raw-mode lease. The /settings layer still determines
87
+ // whether the cached result may control this startup.
91
88
  let followActive;
92
89
  const dataDir = join(homeDir(), '.dsh-tui');
93
- const startFollow = () => {
94
- runFollowSystem(dataDir, message => {
90
+ const applyFollow = () => {
91
+ runFollowSystem(dataDir, () => followActive === true, message => {
95
92
  ctx.logger.info(`dsh-tui-theme: ${message}`);
96
93
  });
97
94
  };
@@ -100,23 +97,16 @@ export function apply(ctx, config = {}) {
100
97
  if (effective.followSystem !== followActive) {
101
98
  followActive = effective.followSystem;
102
99
  if (followActive) {
103
- startFollow();
100
+ applyFollow();
104
101
  }
105
102
  else {
106
103
  ctx.logger.info('dsh-tui-theme: follow: disabled, manual /theme choice preserved');
107
104
  }
108
105
  }
109
106
  });
110
- // Degradation backstop for hosts that never provide a settings service:
111
- // the inject callback never fires there, so the cordis-layer decision
112
- // applies instead. The delay lets any pending service registration (and
113
- // its callback) land first — it no-ops once followActive is settled, and
114
- // stays inside the host's own 300ms pre-mount settings gate either way.
115
- setTimeout(() => {
116
- if (followActive === undefined && cordis.followSystem) {
117
- followActive = true;
118
- startFollow();
119
- }
120
- }, FOLLOW_FALLBACK_MS);
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.
121
111
  startStatusLine(ctx, () => effective);
122
112
  }
@@ -20,7 +20,7 @@ import type { StatusOptions } from './statusLine.js';
20
20
  export type PinkSettingsDoc = StatusOptions & {
21
21
  /** Install bundled theme JSONs on boot (cordis-config layer only). */
22
22
  autoInstallThemes?: boolean;
23
- /** Follow the terminal/system background: pink-day pink-night. */
23
+ /** Apply a cached terminal background: pink-day <-> pink-night. */
24
24
  followSystem?: boolean;
25
25
  };
26
26
  /**
@@ -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,oEAAoE;IACpE,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,CA4CN"}
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"}
@@ -30,29 +30,36 @@ import z from '@deepseek-ai/schemastery';
30
30
  export function registerPinkSettings(ctx, cordis, onDoc) {
31
31
  ctx.inject(['settings'], settingsCtx => {
32
32
  const settings = settingsCtx.settings;
33
- const scope = settings.register(settingsNamespace('dsh-tui-theme'), z.object({
34
- followSystem: z.boolean(),
35
- showGlyph: z.boolean(),
36
- showClock: z.boolean(),
37
- showTurns: z.boolean(),
38
- statusScope: z.union(['pink-only', 'all-themes']),
39
- }));
40
- const emit = (doc) => {
41
- if (doc === null || typeof doc !== 'object')
42
- return;
43
- const clean = {};
44
- for (const [key, value] of Object.entries(doc)) {
45
- if (value !== undefined)
46
- clean[key] = value;
47
- }
48
- onDoc(clean);
49
- };
50
- // Own the watcher on the inject-scoped ledger so it survives exactly as
51
- // long as this activation (scope.watch's disposer is otherwise leaked).
52
- settingsCtx.effect(() => {
53
- emit(scope.get());
54
- return scope.watch(emit);
55
- });
33
+ try {
34
+ const scope = settings.register(settingsNamespace('dsh-tui-theme'), z.object({
35
+ followSystem: z.boolean(),
36
+ showGlyph: z.boolean(),
37
+ showClock: z.boolean(),
38
+ showTurns: z.boolean(),
39
+ statusScope: z.union(['pink-only', 'all-themes']),
40
+ }));
41
+ const emit = (doc) => {
42
+ if (doc === null || typeof doc !== 'object')
43
+ return;
44
+ const clean = {};
45
+ for (const [key, value] of Object.entries(doc)) {
46
+ if (value !== undefined)
47
+ clean[key] = value;
48
+ }
49
+ onDoc(clean);
50
+ };
51
+ // Own the watcher on the inject-scoped ledger so it survives exactly as
52
+ // long as this activation (scope.watch's disposer is otherwise leaked).
53
+ settingsCtx.effect(() => {
54
+ emit(scope.get());
55
+ return scope.watch(emit);
56
+ });
57
+ }
58
+ catch (error) {
59
+ // A duplicate registration (hot reload race) or a stricter host must
60
+ // not take the plugin — or the TUI — down.
61
+ settingsCtx.logger.warn(`dsh-tui-pink-theme: settings namespace registration failed: ${String(error)}`);
62
+ }
56
63
  });
57
64
  ctx.inject(['tuiSettingsSections'], sectionsCtx => {
58
65
  const sections = sectionsCtx
@@ -77,11 +84,11 @@ function sectionDefinition(cordis) {
77
84
  fields: [
78
85
  {
79
86
  path: ['followSystem'],
80
- label: 'Follow terminal background',
81
- descriptions: { zh: '跟随终端背景' },
82
- hint: 'Auto-switch between Pink Day and Pink Night by terminal/system background. First enabling or a background flip applies from the next boot.',
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.',
83
90
  hintDescriptions: {
84
- zh: '按终端/系统背景色在昼樱与夜樱间自动切换。首次开启或背景刚翻转后,自下次启动生效。',
91
+ zh: '启动时应用此前保存的终端背景结果。dsh-TUI 0.9.2 未提供安全的插件查询接缝,因此本插件不会刷新该缓存。',
85
92
  },
86
93
  kind: 'boolean',
87
94
  format: (value) => String(value ?? cordis.followSystem),
@@ -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,CA2DvF"}
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"}
@@ -61,6 +61,7 @@ export function startStatusLine(ctx, getEffective) {
61
61
  const turns = new Map();
62
62
  let current;
63
63
  let dispose;
64
+ let lastText;
64
65
  const render = () => {
65
66
  try {
66
67
  const eff = getEffective();
@@ -79,6 +80,9 @@ export function startStatusLine(ctx, getEffective) {
79
80
  }
80
81
  }
81
82
  const text = parts.join(' · ');
83
+ if (text === lastText)
84
+ return;
85
+ lastText = text;
82
86
  // The trailing identity must be the inject-scoped context (the same
83
87
  // activation the traceable binds as caller) — the plugin's outer ctx
84
88
  // is a different activation view and would be silently rejected.
@@ -88,14 +92,14 @@ export function startStatusLine(ctx, getEffective) {
88
92
  // Display garnish only: a rendering hiccup must never travel upward.
89
93
  }
90
94
  };
91
- ctx.on('session/event', (session, event) => {
95
+ statusCtx.on('session/event', (session, event) => {
92
96
  current = session;
93
97
  if (event?.type === 'turn/end') {
94
98
  turns.set(session, (turns.get(session) ?? 0) + 1);
95
99
  }
96
100
  render();
97
101
  });
98
- ctx.on('session/disposed', session => {
102
+ statusCtx.on('session/disposed', session => {
99
103
  turns.delete(session);
100
104
  if (current === session)
101
105
  current = undefined;
@@ -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,CA2BpB"}
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"}
@@ -7,7 +7,7 @@
7
7
  * theme garnish must never break the TUI's boot.
8
8
  * @module dsh-tui-pink-theme/themeAssets
9
9
  */
10
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
10
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
12
  import { dirname, join } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
@@ -42,17 +42,23 @@ export function installBundledThemes(targetDir = themesTargetDir(), sourceDir =
42
42
  return { installed, skipped, failed: [sourceDir] };
43
43
  }
44
44
  for (const file of files) {
45
+ const target = join(targetDir, file);
45
46
  try {
46
- const target = join(targetDir, file);
47
- if (existsSync(target)) {
48
- skipped.push(file);
49
- continue;
50
- }
51
47
  const text = readFileSync(join(sourceDir, file), 'utf8');
52
48
  JSON.parse(text); // our own asset, but never write a corrupt file out
53
49
  mkdirSync(targetDir, { recursive: true });
54
- writeFileSync(target, text);
55
- installed.push(file);
50
+ try {
51
+ writeFileSync(target, text, { flag: 'wx' });
52
+ installed.push(file);
53
+ }
54
+ catch (error) {
55
+ if (error.code === 'EEXIST') {
56
+ skipped.push(file);
57
+ }
58
+ else {
59
+ failed.push(file);
60
+ }
61
+ }
56
62
  }
57
63
  catch {
58
64
  failed.push(file);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tui-theme",
3
- "version": "0.3.1",
4
- "description": "Sakura-pink themes for dsh-TUI with terminal-background auto-follow (pink-day/pink-night), a blossom status line, and a /settings section. No shortcuts, no commands.",
3
+ "version": "0.4.0",
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",
7
7
  "types": "lib/types/index.d.ts",
@@ -17,6 +17,7 @@
17
17
  "lib",
18
18
  "themes",
19
19
  "docs",
20
+ "scripts",
20
21
  "cordis.patch.yml"
21
22
  ],
22
23
  "engines": {
@@ -24,7 +25,11 @@
24
25
  },
25
26
  "scripts": {
26
27
  "build": "tsc -p tsconfig.json",
27
- "verify": "node scripts/verify.mjs"
28
+ "verify": "node scripts/verify.mjs",
29
+ "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
+ "release:check": "npm run build && npm run verify && npm run verify:package",
32
+ "prepack": "npm run release:check"
28
33
  },
29
34
  "license": "MIT",
30
35
  "author": "xiaoxiaohaigui",
@@ -50,19 +55,19 @@
50
55
  "patch": "./cordis.patch.yml"
51
56
  }
52
57
  },
53
- "dependencies": {
54
- "@deepseek-ai/schemastery": "^3.18.1"
55
- },
56
58
  "peerDependencies": {
57
59
  "@deepseek-ai/cordis": "^4.0.1",
58
60
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.1",
59
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.1-rc.1"
61
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.1-rc.1",
62
+ "@deepseek-ai/schemastery": "^3.18.1"
60
63
  },
61
64
  "devDependencies": {
62
65
  "@deepseek-ai/cordis": "^4.0.1",
63
66
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.1",
64
67
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.1-rc.1",
68
+ "@deepseek-ai/schemastery": "^3.18.1",
65
69
  "@types/node": "^22.0.0",
70
+ "tsx": "^4.23.12",
66
71
  "typescript": "^6.0.3"
67
72
  }
68
73
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * End-to-end order test with the installed dsh-TUI services. Composes this
3
+ * plugin before the extension services to exercise late service injection.
4
+ *
5
+ * DSH_TUI_ADAPTER_DIR must point at dsh-TUI's lib/types/dsh-adapter directory.
6
+ */
7
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import { fileURLToPath, pathToFileURL } from 'node:url'
11
+ import { createRequire } from 'node:module'
12
+ import assert from 'node:assert/strict'
13
+
14
+ const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
15
+ const adapter = process.env.DSH_TUI_ADAPTER_DIR
16
+ if (adapter === undefined || adapter === '') {
17
+ throw new Error('DSH_TUI_ADAPTER_DIR must point at dsh-TUI lib/types/dsh-adapter for this host integration test.')
18
+ }
19
+
20
+ if (!existsSync(join(adapter, 'extensions.js'))) {
21
+ throw new Error(`dsh-TUI adapter not found at ${adapter}`)
22
+ }
23
+
24
+ const hostPackagePath = join(adapter, '..', '..', '..', 'package.json')
25
+ if (!existsSync(hostPackagePath)) {
26
+ throw new Error(`dsh-TUI package metadata not found above adapter at ${adapter}`)
27
+ }
28
+ 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}`)
30
+
31
+ const sandbox = mkdtempSync(join(tmpdir(), 'pink-order-'))
32
+ process.env.USERPROFILE = sandbox
33
+ process.env.HOME = sandbox
34
+
35
+ const dataDir = join(sandbox, '.dsh-tui')
36
+ mkdirSync(dataDir, { recursive: true })
37
+ writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
38
+
39
+ const req = createRequire(join(adapter, 'extensions.js'))
40
+ const { Context } = await import(pathToFileURL(req.resolve('@deepseek-ai/cordis')).href)
41
+ const extensions = await import(pathToFileURL(join(adapter, 'extensions.js')).href)
42
+ const ledgerModule = await import(pathToFileURL(join(adapter, 'effect-ledger.js')).href)
43
+ const statusModule = await import(pathToFileURL(join(adapter, 'status.js')).href)
44
+ const pink = await import(pathToFileURL(join(pluginRoot, 'lib', 'types', 'index.js')).href)
45
+
46
+ const app = new Context()
47
+ await app.plugin(ledgerModule.default)
48
+ await app.plugin(pink)
49
+ await app.plugin(extensions.default ?? extensions)
50
+ await new Promise(resolve => setTimeout(resolve, 300))
51
+
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()
61
+
62
+ assert.ok(pinkBinds.length > 0, 'plugin must bind through the late status service')
63
+ assert.ok(
64
+ snapshot?.some?.(entry => entry.key === 'dsh-tui-theme'),
65
+ 'status store must contain the plugin contribution',
66
+ )
67
+ console.log(`✓ headless order: ${pinkBinds.length} ledger bind(s), visible status contribution`)
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Validate bundled themes against the real dsh-TUI source implementation.
3
+ * DSH_TUI_SOURCE_ROOT must point at a dsh-TUI source checkout.
4
+ *
5
+ * Run with: npx -y tsx scripts/validate-themes-against-host.mjs
6
+ */
7
+ import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join, resolve } from 'node:path'
10
+ import { fileURLToPath, pathToFileURL } from 'node:url'
11
+ import assert from 'node:assert/strict'
12
+
13
+ const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
14
+ const sourceRoot = process.env.DSH_TUI_SOURCE_ROOT
15
+ if (sourceRoot === undefined || sourceRoot === '') {
16
+ throw new Error('DSH_TUI_SOURCE_ROOT must point at a dsh-TUI source checkout for this host theme validation.')
17
+ }
18
+ const hostRoot = resolve(sourceRoot)
19
+ const customThemePath = join(hostRoot, 'src', 'customTheme.ts')
20
+ const themePath = join(hostRoot, 'src', 'theme.ts')
21
+
22
+ if (!existsSync(customThemePath) || !existsSync(themePath)) {
23
+ throw new Error(`dsh-TUI sources not found at ${hostRoot}`)
24
+ }
25
+
26
+ const hostPackagePath = join(hostRoot, 'package.json')
27
+ if (!existsSync(hostPackagePath)) {
28
+ throw new Error(`dsh-TUI package metadata not found at ${hostPackagePath}`)
29
+ }
30
+ 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}`)
32
+
33
+ const sandboxHome = mkdtempSync(join(tmpdir(), 'pink-theme-host-validate-'))
34
+ process.env.USERPROFILE = sandboxHome
35
+ process.env.HOME = sandboxHome
36
+
37
+ const themesDir = join(pluginRoot, 'themes')
38
+ const { parseCustomTheme, buildTheme } = await import(pathToFileURL(customThemePath).href)
39
+ const { getTheme, isLightThemeActive, registerCustomThemeResolver } = await import(
40
+ pathToFileURL(themePath).href
41
+ )
42
+
43
+ const allKeys = Object.keys(getTheme('dark'))
44
+ assert.ok(allKeys.length >= 90, 'host Theme key count drifted; re-check coverage')
45
+
46
+ function parseColor(value) {
47
+ if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
48
+ const n = Number.parseInt(value.slice(1), 16)
49
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
50
+ }
51
+ const match = /^rgb\((\d+),(\d+),(\d+)\)$/.exec(value)
52
+ return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])]
53
+ }
54
+
55
+ function luminance(color) {
56
+ const channel = value => {
57
+ const normalized = value / 255
58
+ return normalized <= 0.03928
59
+ ? normalized / 12.92
60
+ : ((normalized + 0.055) / 1.055) ** 2.4
61
+ }
62
+ return 0.2126 * channel(color[0]) + 0.7152 * channel(color[1]) + 0.0722 * channel(color[2])
63
+ }
64
+
65
+ function contrast(foreground, background) {
66
+ const [high, low] = [luminance(foreground), luminance(background)].sort((a, b) => b - a)
67
+ return (high + 0.05) / (low + 0.05)
68
+ }
69
+
70
+ const expectedLight = { 'pink-night': false, 'pink-day': true, 'pink-ansi': false }
71
+ const built = {}
72
+ registerCustomThemeResolver(name => built[name])
73
+
74
+ for (const file of readdirSync(themesDir).filter(name => name.endsWith('.json'))) {
75
+ const warnings = []
76
+ const originalWarn = console.warn
77
+ console.warn = message => warnings.push(String(message))
78
+ let spec
79
+ try {
80
+ spec = parseCustomTheme(readFileSync(join(themesDir, file), 'utf8'), file)
81
+ } finally {
82
+ console.warn = originalWarn
83
+ }
84
+
85
+ assert.notEqual(spec, undefined, `${file} must parse`)
86
+ assert.deepEqual(warnings, [], `${file} must produce zero host warnings`)
87
+ const missing = allKeys.filter(key => key !== 'userMessageBackground' && !(key in spec.colors))
88
+ assert.deepEqual(missing, [], `${file} must cover every Theme key`)
89
+
90
+ const theme = buildTheme(spec)
91
+ built[spec.name] = theme
92
+ assert.equal(isLightThemeActive(spec.name), expectedLight[spec.name], `${spec.name} identity`)
93
+ }
94
+
95
+ for (const [name, light] of Object.entries(expectedLight)) {
96
+ assert.equal(isLightThemeActive(name), light, `${name} identity via resolver`)
97
+ }
98
+
99
+ const cases = [
100
+ ['pink-night', 'text', '#1E1E1E', 4.5],
101
+ ['pink-night', 'claude', '#1E1E1E', 3.0],
102
+ ['pink-night', 'inactive', '#1E1E1E', 2.5],
103
+ ['pink-day', 'text', '#F6F3ED', 4.5],
104
+ ['pink-day', 'claude', '#F6F3ED', 3.0],
105
+ ['pink-day', 'inactive', '#F6F3ED', 2.5],
106
+ ]
107
+ for (const [theme, key, background, minimum] of cases) {
108
+ const foregroundRgb = parseColor(built[theme][key])
109
+ const backgroundRgb = parseColor(background)
110
+ assert.notEqual(foregroundRgb, undefined, `${theme}.${key} must be parseable`)
111
+ const ratio = contrast(foregroundRgb, backgroundRgb)
112
+ assert.ok(ratio >= minimum, `${theme}.${key} contrast ${ratio.toFixed(2)} >= ${minimum}`)
113
+ }
114
+
115
+ console.log(`✓ host theme validation: ${Object.keys(built).length} themes, ${allKeys.length - 1} keys each`)
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Release artifact contract: inspect npm's dry-run manifest without creating
3
+ * a tarball, then ensure consumers receive every advertised runtime asset.
4
+ */
5
+ import { execFileSync } from 'node:child_process'
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+ import assert from 'node:assert/strict'
9
+
10
+ const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
11
+ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
12
+ const lockfile = JSON.parse(readFileSync(new URL('../package-lock.json', import.meta.url), 'utf8'))
13
+ const packCommand = process.platform === 'win32'
14
+ ? { file: 'cmd.exe', args: ['/d', '/s', '/c', 'npm pack --dry-run --json --ignore-scripts'] }
15
+ : { file: 'npm', args: ['pack', '--dry-run', '--json', '--ignore-scripts'] }
16
+ const manifest = JSON.parse(execFileSync(packCommand.file, packCommand.args, {
17
+ cwd: pluginRoot,
18
+ encoding: 'utf8',
19
+ }))
20
+ const packed = manifest[0]
21
+ assert.ok(packed, 'npm pack must report one package')
22
+ assert.equal(packed.name, 'dsh-tui-theme')
23
+ assert.equal(packed.version, packageJson.version)
24
+
25
+ const files = new Set(packed.files.map(entry => entry.path))
26
+ for (const required of [
27
+ 'package.json',
28
+ 'README.md',
29
+ 'LICENSE',
30
+ 'cordis.patch.yml',
31
+ 'lib/types/index.js',
32
+ 'lib/types/index.d.ts',
33
+ 'lib/types/autoTheme.js',
34
+ 'lib/types/autoTheme.d.ts',
35
+ 'lib/types/settingsSection.js',
36
+ 'lib/types/settingsSection.d.ts',
37
+ 'lib/types/statusLine.js',
38
+ 'lib/types/statusLine.d.ts',
39
+ 'lib/types/themeAssets.js',
40
+ 'lib/types/themeAssets.d.ts',
41
+ 'themes/pink-night.json',
42
+ 'themes/pink-day.json',
43
+ 'themes/pink-ansi.json',
44
+ 'docs/screenshots/pink-day.png',
45
+ 'docs/screenshots/pink-night.png',
46
+ 'docs/screenshots/settings.png',
47
+ 'scripts/verify.mjs',
48
+ 'scripts/verify-package.mjs',
49
+ 'scripts/headless-order-test.mjs',
50
+ 'scripts/validate-themes-against-host.mjs',
51
+ ]) {
52
+ assert.ok(files.has(required), `published package must include ${required}`)
53
+ }
54
+
55
+ const exportedPaths = [packageJson.main, packageJson.types, packageJson.dsh.bundle.patch]
56
+ for (const entry of Object.values(packageJson.exports)) {
57
+ if (typeof entry === 'string') exportedPaths.push(entry)
58
+ else if (entry !== null && typeof entry === 'object') exportedPaths.push(...Object.values(entry))
59
+ }
60
+ for (const entry of exportedPaths) {
61
+ assert.equal(typeof entry, 'string')
62
+ assert.ok(files.has(entry.replace(/^\.\//, '')), `published package must include declared entry ${entry}`)
63
+ }
64
+
65
+ for (const name of Object.keys(packageJson.peerDependencies)) {
66
+ if (!name.startsWith('@deepseek-ai/')) continue
67
+ assert.equal(packageJson.dependencies?.[name], undefined, `${name} must not be a runtime dependency`)
68
+ assert.equal(packageJson.devDependencies?.[name], packageJson.peerDependencies[name], `${name} peer and dev ranges must match`)
69
+ }
70
+ assert.equal(packageJson.dependencies?.['@deepseek-ai/schemastery'], undefined)
71
+ const rootLock = lockfile.packages?.['']
72
+ assert.ok(rootLock, 'lockfile must have root metadata')
73
+ assert.equal(lockfile.version, packageJson.version)
74
+ assert.deepEqual(rootLock.devDependencies, packageJson.devDependencies)
75
+ assert.deepEqual(rootLock.peerDependencies, packageJson.peerDependencies)
76
+ assert.equal(rootLock.dependencies, undefined)
77
+ assert.equal(existsSync(new URL('../lib/types/index.js', import.meta.url)), true)
78
+
79
+ console.log(`✓ package manifest: ${packed.name}@${packed.version}, ${files.size} files`)