dsh-tui-theme 0.3.2 → 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.
@@ -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
  *
@@ -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,56 +67,55 @@ 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 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.
82
+ // Background follow applies only an existing cache. dsh-TUI exposes
83
+ // no plugin terminal-query seam, so a theme plugin must not compete with
84
+ // Ink's stdin reader or raw-mode lease. The /settings layer still determines
85
+ // whether the cached result may control this startup.
91
86
  let followActive;
92
87
  const dataDir = join(homeDir(), '.dsh-tui');
93
- // Live gate for in-flight detections: a reply that lands after the user
94
- // turned follow off must not rewrite the pref.
95
- const followEnabled = () => followActive === true;
96
- const startFollow = () => {
97
- runFollowSystem(dataDir, followEnabled, message => {
98
- ctx.logger.info(`dsh-tui-theme: ${message}`);
88
+ const applyFollow = () => {
89
+ runFollowSystem(dataDir, () => followActive === true, message => {
90
+ ctx.logger.info(`${PLUGIN_ID}: ${message}`);
99
91
  });
100
92
  };
101
93
  registerPinkSettings(ctx, cordis, doc => {
102
94
  effective = { ...cordis, ...doc };
103
- if (effective.followSystem !== followActive) {
104
- followActive = effective.followSystem;
105
- if (followActive) {
106
- startFollow();
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) {
108
+ applyFollow();
107
109
  }
108
110
  else {
109
- ctx.logger.info('dsh-tui-theme: follow: disabled, manual /theme choice preserved');
111
+ ctx.logger.info(`${PLUGIN_ID}: follow: disabled, manual /theme choice preserved`);
110
112
  }
111
113
  }
112
114
  });
113
- // Degradation backstop for hosts that never provide a settings service:
114
- // the inject callback never fires there, so the cordis-layer decision
115
- // applies instead. The delay lets any pending service registration (and
116
- // its callback) land first it no-ops once followActive is settled, and
117
- // stays inside the host's own 300ms pre-mount settings gate either way.
118
- const fallbackTimer = setTimeout(() => {
119
- if (followActive === undefined && cordis.followSystem) {
120
- followActive = true;
121
- startFollow();
122
- }
123
- }, FOLLOW_FALLBACK_MS);
124
- fallbackTimer.unref?.();
125
- ctx.effect(() => () => clearTimeout(fallbackTimer));
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.
126
120
  startStatusLine(ctx, () => effective);
127
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';
@@ -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,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: 'Follow terminal background',
88
- descriptions: { zh: '跟随终端背景' },
89
- hint: 'Auto-switch between Pink Day and Pink Night by terminal/system background. First enabling or a background flip applies from the next boot.',
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: '按终端/系统背景色在昼樱与夜樱间自动切换。首次开启或背景刚翻转后,自下次启动生效。',
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;
@@ -92,14 +100,14 @@ export function startStatusLine(ctx, getEffective) {
92
100
  // Display garnish only: a rendering hiccup must never travel upward.
93
101
  }
94
102
  };
95
- ctx.on('session/event', (session, event) => {
103
+ statusCtx.on('session/event', (session, event) => {
96
104
  current = session;
97
105
  if (event?.type === 'turn/end') {
98
106
  turns.set(session, (turns.get(session) ?? 0) + 1);
99
107
  }
100
108
  render();
101
109
  });
102
- ctx.on('session/disposed', session => {
110
+ statusCtx.on('session/disposed', session => {
103
111
  turns.delete(session);
104
112
  if (current === session)
105
113
  current = undefined;
@@ -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,CA2BpB"}
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 { existsSync, 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,30 +36,73 @@ 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) {
49
+ const target = join(targetDir, file);
45
50
  try {
46
- const target = join(targetDir, file);
47
- if (existsSync(target)) {
48
- skipped.push(file);
49
- continue;
50
- }
51
51
  const text = readFileSync(join(sourceDir, file), 'utf8');
52
52
  JSON.parse(text); // our own asset, but never write a corrupt file out
53
53
  mkdirSync(targetDir, { recursive: true });
54
- writeFileSync(target, text);
55
- installed.push(file);
54
+ try {
55
+ writeFileSync(target, text, { flag: 'wx' });
56
+ installed.push(file);
57
+ }
58
+ catch (error) {
59
+ if (error.code === 'EEXIST') {
60
+ if (healCorruptTarget(target, text))
61
+ repaired.push(file);
62
+ else
63
+ skipped.push(file);
64
+ }
65
+ else {
66
+ failed.push(file);
67
+ }
68
+ }
56
69
  }
57
70
  catch {
58
71
  failed.push(file);
59
72
  }
60
73
  }
61
- 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
+ }
62
108
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tui-theme",
3
- "version": "0.3.2",
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.5.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": {
@@ -25,7 +26,11 @@
25
26
  "scripts": {
26
27
  "build": "tsc -p tsconfig.json",
27
28
  "preverify": "npm run build",
28
- "verify": "node scripts/verify.mjs"
29
+ "verify": "node scripts/verify.mjs",
30
+ "verify:package": "node scripts/verify-package.mjs",
31
+ "verify:host": "npm run build && node scripts/headless-order-test.mjs && node --import tsx/esm scripts/validate-themes-against-host.mjs",
32
+ "release:check": "npm run build && npm run verify && npm run verify:package",
33
+ "prepack": "npm run release:check"
29
34
  },
30
35
  "license": "MIT",
31
36
  "author": "xiaoxiaohaigui",
@@ -51,19 +56,19 @@
51
56
  "patch": "./cordis.patch.yml"
52
57
  }
53
58
  },
54
- "dependencies": {
55
- "@deepseek-ai/schemastery": "^3.18.1"
56
- },
57
59
  "peerDependencies": {
58
60
  "@deepseek-ai/cordis": "^4.0.1",
59
61
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.1",
60
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.1-rc.1"
62
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.1-rc.1",
63
+ "@deepseek-ai/schemastery": "^3.18.1"
61
64
  },
62
65
  "devDependencies": {
63
66
  "@deepseek-ai/cordis": "^4.0.1",
64
67
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.1",
65
68
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.1-rc.1",
69
+ "@deepseek-ai/schemastery": "^3.18.1",
66
70
  "@types/node": "^22.0.0",
71
+ "tsx": "^4.23.12",
67
72
  "typescript": "^6.0.3"
68
73
  }
69
74
  }
@@ -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
+ }