dsh-tui-theme 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,12 +2,34 @@
2
2
  * /settings integration (seam six: tuiSettingsSections over the dsh settings
3
3
  * service).
4
4
  *
5
- * Registers the `pink-theme` settings namespace and a declarative editing
6
- * section for it. Storage, schema validation, and layered resolution stay on
7
- * the dsh settings service; the TUI only renders. Fields carry no schema
8
- * defaults on purpose — an unset user layer must fall through to the cordis
9
- * config layer (mirrors the host's own lang/fullscreen fields), and format()
10
- * displays the effective value instead of a misleading blank.
5
+ * Registers a declarative editing section for the plugin's settings and, on
6
+ * hosts that still have the registration API, the settings namespace itself.
7
+ * Storage, schema validation, and layered resolution stay on the dsh settings
8
+ * service; the TUI only renders. Fields carry no schema defaults on purpose —
9
+ * an unset user layer must fall through to the cordis config layer (mirrors
10
+ * the host's own lang/fullscreen fields), and format() displays the effective
11
+ * value instead of a misleading blank.
12
+ *
13
+ * Two generations, branched by capability (never by version),
14
+ * `dsh-settings`'s 0.1.7 break being the reason
15
+ * (docs/decisions/2026-09-24-settings-generation-adaptation.md):
16
+ *
17
+ * - **≤0.1.6** (dsh-TUI 0.9.x/0.10.x) — `settings.register(ns, schema)` owns
18
+ * the namespace. The card's paths resolve against the scope schema declared
19
+ * here, and `scope.watch` pushes edits into the plugin.
20
+ * - **≥0.1.7** (dsh-TUI 0.11+) — there is no registration API at all
21
+ * (`SettingsForms` projects each profile entry's *Config* instead). The
22
+ * namespace is this plugin's profile entry id (see
23
+ * {@link resolveSettingsNamespace}) and the form schema is `Config` filtered
24
+ * to its volatile fields (liveConfig.ts). Edits land in the profile patch,
25
+ * the loader rewrites those same refs in place, and
26
+ * `loader/volatile-update` announces it; this module re-reads the live config
27
+ * on that event. The card stays ours, so the auto-generated page is turned
28
+ * off with `configure({ auto: false })`.
29
+ *
30
+ * A miss in either direction is silent and user-visible only as a card that
31
+ * renders but never saves (≤0.1.6) or a `命名空间未注册` badge (≥0.1.7) — hence
32
+ * the branch below warns instead of swallowing the failure.
11
33
  *
12
34
  * The section splits into two navigation groups (背景跟随 / 状态行). The two
13
35
  * text fields (glyph, separator) validate their drafts with parse(): an
@@ -26,18 +48,54 @@ export type PinkSettingsDoc = StatusOptions & {
26
48
  /** Apply a cached terminal background: pink-day <-> pink-night. */
27
49
  followSystem?: boolean;
28
50
  };
51
+ /** The settings namespace this plugin owns by default, and the fallback when
52
+ * the Loader entry id is unusable. On ≥0.1.7 hosts the *entry id* is the
53
+ * namespace the host keys by, so the effective value is
54
+ * {@link resolveSettingsNamespace}: a default install lands on exactly this
55
+ * string (`cordis.patch.yml` pins `id: dsh-tui-theme`), and it stays the name
56
+ * the README documents for settings storage. */
57
+ export declare const SETTINGS_NS = "dsh-tui-theme";
58
+ /**
59
+ * The namespace this plugin's settings live under: the Loader entry id when it
60
+ * satisfies the section grammar, {@link SETTINGS_NS} otherwise.
61
+ *
62
+ * A `dsh-settings` ≥0.1.7 host keys namespaces by the Loader entry id
63
+ * (`SettingsForms.describe()` → `entry.options.id`), so keying the card off
64
+ * anything else breaks the moment the row is renamed — the fragility dsh-TUI
65
+ * #990 records, where even the host's own section had to stop hard-coding its
66
+ * name. Both generations resolve to the same string here, so the card, the
67
+ * legacy registration and the host's projection cannot disagree.
68
+ */
69
+ export declare function resolveSettingsNamespace(ctx: Context): string;
70
+ /** Everything the wiring needs from the plugin's own activation. */
71
+ export interface PinkSettingsWiring {
72
+ /** The cordis-config layer (the effective value format() falls back to for
73
+ * still-unset fields). */
74
+ readonly cordis: StatusOptions & {
75
+ followSystem?: boolean;
76
+ };
77
+ /** Current plain values of the plugin's own row config. On ≥0.1.7 hosts the
78
+ * loader rewrites the live refs in place, so calling this again after
79
+ * `loader/volatile-update` yields the edited values. */
80
+ readLive(): PinkSettingsDoc;
81
+ /** Whether the row-config schema carries the live marker
82
+ * (`hasLiveConfigFields(Config)`); diagnostics only. */
83
+ readonly hasLiveFields: boolean;
84
+ /** Receives the defined-valued subset of the settings doc — the initial one
85
+ * and every later edit. */
86
+ onDoc(doc: PinkSettingsDoc): void;
87
+ }
29
88
  /**
30
- * Register the settings namespace (mirror the resolved document to the
31
- * caller) and, separately, the /settings section for it. Each part waits for
32
- * its own service; neither is required for the other.
89
+ * Register the /settings section and, on hosts that still have the
90
+ * registration API, the settings namespace behind it. Each part waits for its
91
+ * own service; neither is required for the other.
33
92
  *
34
- * @param ctx - The plugin's own activation context.
35
- * @param cordis - The cordis-config layer (shown as the effective value for
36
- * still-unset fields).
37
- * @param onDoc - Called with the defined-valued subset of the settings doc,
38
- * initially and on every committed edit.
93
+ * @param ctx - The plugin's own activation context: the Config owner the
94
+ * ≥0.1.7 page policy and the volatile-update listener must attach to (their
95
+ * disposers ride the inject child, which is what a service reload recycles).
96
+ * @param wiring - The value source and sink for the card.
39
97
  * @param dataDir - The host data directory (~/.dsh-tui), read by the
40
98
  * followSystem field's format() to surface the cached follow state.
41
99
  */
42
- export declare function registerPinkSettings(ctx: Context, cordis: StatusOptions, onDoc: (doc: PinkSettingsDoc) => void, dataDir?: string): void;
100
+ export declare function registerPinkSettings(ctx: Context, wiring: PinkSettingsWiring, dataDir?: string): void;
43
101
  //# sourceMappingURL=settingsSection.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"settingsSection.d.ts","sourceRoot":"","sources":["../../src/settingsSection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAMlD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAIpD,+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;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,aAAa,EACrB,KAAK,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,IAAI,EACrC,OAAO,GAAE,MAA0B,GAClC,IAAI,CA0DN"}
1
+ {"version":3,"file":"settingsSection.d.ts","sourceRoot":"","sources":["../../src/settingsSection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAMlD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAIpD,+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;AAkBD;;;;;iDAKiD;AACjD,eAAO,MAAM,WAAW,kBAAY,CAAA;AAcpC;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAG7D;AAED,oEAAoE;AACpE,MAAM,WAAW,kBAAkB;IACjC;+BAC2B;IAC3B,QAAQ,CAAC,MAAM,EAAE,aAAa,GAAG;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;IAC3D;;6DAEyD;IACzD,QAAQ,IAAI,eAAe,CAAA;IAC3B;6DACyD;IACzD,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAA;IAC/B;gCAC4B;IAC5B,KAAK,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,CAAA;CAClC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,kBAAkB,EAC1B,OAAO,GAAE,MAA0B,GAClC,IAAI,CAqCN"}
@@ -2,12 +2,34 @@
2
2
  * /settings integration (seam six: tuiSettingsSections over the dsh settings
3
3
  * service).
4
4
  *
5
- * Registers the `pink-theme` settings namespace and a declarative editing
6
- * section for it. Storage, schema validation, and layered resolution stay on
7
- * the dsh settings service; the TUI only renders. Fields carry no schema
8
- * defaults on purpose — an unset user layer must fall through to the cordis
9
- * config layer (mirrors the host's own lang/fullscreen fields), and format()
10
- * displays the effective value instead of a misleading blank.
5
+ * Registers a declarative editing section for the plugin's settings and, on
6
+ * hosts that still have the registration API, the settings namespace itself.
7
+ * Storage, schema validation, and layered resolution stay on the dsh settings
8
+ * service; the TUI only renders. Fields carry no schema defaults on purpose —
9
+ * an unset user layer must fall through to the cordis config layer (mirrors
10
+ * the host's own lang/fullscreen fields), and format() displays the effective
11
+ * value instead of a misleading blank.
12
+ *
13
+ * Two generations, branched by capability (never by version),
14
+ * `dsh-settings`'s 0.1.7 break being the reason
15
+ * (docs/decisions/2026-09-24-settings-generation-adaptation.md):
16
+ *
17
+ * - **≤0.1.6** (dsh-TUI 0.9.x/0.10.x) — `settings.register(ns, schema)` owns
18
+ * the namespace. The card's paths resolve against the scope schema declared
19
+ * here, and `scope.watch` pushes edits into the plugin.
20
+ * - **≥0.1.7** (dsh-TUI 0.11+) — there is no registration API at all
21
+ * (`SettingsForms` projects each profile entry's *Config* instead). The
22
+ * namespace is this plugin's profile entry id (see
23
+ * {@link resolveSettingsNamespace}) and the form schema is `Config` filtered
24
+ * to its volatile fields (liveConfig.ts). Edits land in the profile patch,
25
+ * the loader rewrites those same refs in place, and
26
+ * `loader/volatile-update` announces it; this module re-reads the live config
27
+ * on that event. The card stays ours, so the auto-generated page is turned
28
+ * off with `configure({ auto: false })`.
29
+ *
30
+ * A miss in either direction is silent and user-visible only as a card that
31
+ * renders but never saves (≤0.1.6) or a `命名空间未注册` badge (≥0.1.7) — hence
32
+ * the branch below warns instead of swallowing the failure.
11
33
  *
12
34
  * The section splits into two navigation groups (背景跟随 / 状态行). The two
13
35
  * text fields (glyph, separator) validate their drafts with parse(): an
@@ -23,64 +45,74 @@ import { readFollowCache } from './autoTheme.js';
23
45
  import { parseOrnamentDraft } from './ornament.js';
24
46
  import { PLUGIN_ID } from './pluginId.js';
25
47
  import { homeDir } from './themeAssets.js';
48
+ /** The settings namespace this plugin owns by default, and the fallback when
49
+ * the Loader entry id is unusable. On ≥0.1.7 hosts the *entry id* is the
50
+ * namespace the host keys by, so the effective value is
51
+ * {@link resolveSettingsNamespace}: a default install lands on exactly this
52
+ * string (`cordis.patch.yml` pins `id: dsh-tui-theme`), and it stays the name
53
+ * the README documents for settings storage. */
54
+ export const SETTINGS_NS = PLUGIN_ID;
55
+ /** The grammar plugin-owned sections must satisfy
56
+ * (`tuiSettingsSections.register`; the host only relaxes it for its own
57
+ * sections, which may carry opaque Loader ids). */
58
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9_-]*$/;
59
+ /** The plugin's Loader entry id, when the host gives us one. */
60
+ function loaderEntryId(ctx) {
61
+ const fiber = ctx.fiber;
62
+ const id = fiber?.entry?.options?.id;
63
+ return typeof id === 'string' ? id : undefined;
64
+ }
65
+ /**
66
+ * The namespace this plugin's settings live under: the Loader entry id when it
67
+ * satisfies the section grammar, {@link SETTINGS_NS} otherwise.
68
+ *
69
+ * A `dsh-settings` ≥0.1.7 host keys namespaces by the Loader entry id
70
+ * (`SettingsForms.describe()` → `entry.options.id`), so keying the card off
71
+ * anything else breaks the moment the row is renamed — the fragility dsh-TUI
72
+ * #990 records, where even the host's own section had to stop hard-coding its
73
+ * name. Both generations resolve to the same string here, so the card, the
74
+ * legacy registration and the host's projection cannot disagree.
75
+ */
76
+ export function resolveSettingsNamespace(ctx) {
77
+ const id = loaderEntryId(ctx);
78
+ return id !== undefined && NAMESPACE_PATTERN.test(id) ? id : SETTINGS_NS;
79
+ }
26
80
  /**
27
- * Register the settings namespace (mirror the resolved document to the
28
- * caller) and, separately, the /settings section for it. Each part waits for
29
- * its own service; neither is required for the other.
81
+ * Register the /settings section and, on hosts that still have the
82
+ * registration API, the settings namespace behind it. Each part waits for its
83
+ * own service; neither is required for the other.
30
84
  *
31
- * @param ctx - The plugin's own activation context.
32
- * @param cordis - The cordis-config layer (shown as the effective value for
33
- * still-unset fields).
34
- * @param onDoc - Called with the defined-valued subset of the settings doc,
35
- * initially and on every committed edit.
85
+ * @param ctx - The plugin's own activation context: the Config owner the
86
+ * ≥0.1.7 page policy and the volatile-update listener must attach to (their
87
+ * disposers ride the inject child, which is what a service reload recycles).
88
+ * @param wiring - The value source and sink for the card.
36
89
  * @param dataDir - The host data directory (~/.dsh-tui), read by the
37
90
  * followSystem field's format() to surface the cached follow state.
38
91
  */
39
- export function registerPinkSettings(ctx, cordis, onDoc, dataDir = joinHomeDataDir()) {
92
+ export function registerPinkSettings(ctx, wiring, dataDir = joinHomeDataDir()) {
93
+ const ns = resolveSettingsNamespace(ctx);
40
94
  ctx.inject(['settings'], settingsCtx => {
41
95
  const settings = settingsCtx.settings;
42
- try {
43
- // dsh-settings validates the raw namespace at registration time. Keep
44
- // this as a plain string so alpha.2 hosts, which removed the runtime
45
- // settingsNamespace() helper, can load the plugin without a missing
46
- // named export while older hosts retain the same behavior.
47
- const scope = settings.register(PLUGIN_ID, z.object({
48
- followSystem: z.boolean(),
49
- showGlyph: z.boolean(),
50
- showClock: z.boolean(),
51
- showTurns: z.boolean(),
52
- statusScope: z.union(['pink-only', 'all-themes']),
53
- statusGlyph: z.string(),
54
- statusSeparator: z.string(),
55
- }));
56
- const emit = (doc) => {
57
- if (doc === null || typeof doc !== 'object')
58
- return;
59
- const clean = {};
60
- for (const [key, value] of Object.entries(doc)) {
61
- if (value !== undefined)
62
- clean[key] = value;
63
- }
64
- onDoc(clean);
65
- };
66
- // Own the watcher on the inject-scoped ledger so it survives exactly as
67
- // long as this activation (scope.watch's disposer is otherwise leaked).
68
- settingsCtx.effect(() => {
69
- emit(scope.get());
70
- return scope.watch(emit);
71
- });
96
+ if (settings === undefined)
97
+ return;
98
+ if (typeof settings.register === 'function') {
99
+ registerNamespaceScope(settingsCtx, settings, wiring, ns);
100
+ return;
72
101
  }
73
- catch (error) {
74
- // A duplicate registration (hot reload race) or a stricter host must
75
- // not take the plugin — or the TUI — down.
76
- settingsCtx.logger.warn(`dsh-tui-theme: settings namespace registration failed: ${String(error)}`);
102
+ if (typeof settings.configure !== 'function') {
103
+ settingsCtx.logger.info('dsh-tui-theme: the settings service exposes neither the namespace registration nor the Config-derived surface; the settings card stays unavailable this session');
104
+ return;
77
105
  }
106
+ diagnoseConfigGeneration(ctx, ns, wiring.hasLiveFields);
107
+ configureOwnPage(ctx, settingsCtx, settings);
108
+ wiring.onDoc(definedOnly(wiring.readLive()));
109
+ watchLiveConfig(ctx, settingsCtx, wiring);
78
110
  });
79
111
  ctx.inject(['tuiSettingsSections'], sectionsCtx => {
80
112
  const sections = sectionsCtx
81
113
  .tuiSettingsSections;
82
114
  try {
83
- const unregister = sections.register(sectionDefinition(cordis, dataDir));
115
+ const unregister = sections.register(sectionDefinition(ns, wiring.cordis, dataDir));
84
116
  sectionsCtx.effect(() => () => unregister());
85
117
  }
86
118
  catch (error) {
@@ -90,6 +122,136 @@ export function registerPinkSettings(ctx, cordis, onDoc, dataDir = joinHomeDataD
90
122
  }
91
123
  });
92
124
  }
125
+ /** The defined-valued subset of a settings document: the ≤0.1.6 user layer and
126
+ * the ≥0.1.7 live config both arrive with unset keys present but undefined. */
127
+ function definedOnly(doc) {
128
+ if (doc === null || typeof doc !== 'object')
129
+ return {};
130
+ const clean = {};
131
+ for (const [key, value] of Object.entries(doc)) {
132
+ if (value !== undefined)
133
+ clean[key] = value;
134
+ }
135
+ return clean;
136
+ }
137
+ /**
138
+ * ≤0.1.6 generation: own the namespace and follow its scope. The schema spells
139
+ * out the same field set the card exposes and deliberately carries no defaults
140
+ * — an unset user layer must fall through to the cordis layer, which format()
141
+ * already displays as the effective value.
142
+ */
143
+ function registerNamespaceScope(settingsCtx, settings, wiring, ns) {
144
+ try {
145
+ // Call it as a method (optional-call on the property): the provider's
146
+ // register() reads its own state, so a detached reference would lose
147
+ // `this` and throw.
148
+ //
149
+ // The namespace is passed as a plain string: dsh-settings validates the
150
+ // raw value at registration time, and alpha.2 hosts removed the runtime
151
+ // settingsNamespace() helper, so a branded value would be a missing named
152
+ // export there while older hosts keep the same behavior.
153
+ const scope = settings.register?.(ns, z.object({
154
+ followSystem: z.boolean(),
155
+ showGlyph: z.boolean(),
156
+ showClock: z.boolean(),
157
+ showTurns: z.boolean(),
158
+ statusScope: z.union(['pink-only', 'all-themes']),
159
+ statusGlyph: z.string(),
160
+ statusSeparator: z.string(),
161
+ }));
162
+ if (scope === undefined)
163
+ throw new Error('the settings service exposes no register()');
164
+ // Own the watcher on the inject-scoped ledger so it survives exactly as
165
+ // long as this activation (scope.watch's disposer is otherwise leaked).
166
+ const emit = (doc) => {
167
+ wiring.onDoc(definedOnly(doc));
168
+ };
169
+ settingsCtx.effect(() => {
170
+ emit(scope.get());
171
+ return scope.watch(emit);
172
+ });
173
+ }
174
+ catch (error) {
175
+ // Contained, but never silent: swallowing the provider's error is what
176
+ // made the ≥0.1.7 transition surface as a bare `命名空间未注册` badge with
177
+ // no line in the log to explain it.
178
+ settingsCtx.logger.warn(`dsh-tui-theme: settings namespace registration failed: ${String(error)}`);
179
+ }
180
+ }
181
+ /**
182
+ * Explain the two ways a ≥0.1.7 host can leave this card unserved, instead of
183
+ * letting `命名空间未注册` be the only clue (the diagnosis cost of dsh-TUI #990).
184
+ * Both are warnings, not failures: the row config and every other seam keep
185
+ * working.
186
+ */
187
+ function diagnoseConfigGeneration(ctx, ns, hasLiveFields) {
188
+ const entryId = loaderEntryId(ctx);
189
+ if (entryId !== undefined && entryId !== ns) {
190
+ ctx.logger.warn(`dsh-tui-theme: the settings service keys namespaces by Loader entry id "${entryId}", which is not a valid section namespace — the settings card cannot be served; rename the plugin row to a lowercase kebab-case id`);
191
+ }
192
+ if (!hasLiveFields) {
193
+ ctx.logger.warn('dsh-tui-theme: no row-config field carries the live marker — this host cannot serve the settings card (needs a schemastery that accepts volatile fields); the row config keeps working');
194
+ }
195
+ }
196
+ /**
197
+ * ≥0.1.7 generation: the namespace is this profile entry and its form schema is
198
+ * the marked slice of `Config`, so there is nothing to register. The card is
199
+ * the plugin's own page, so opt out of the auto-generated one; the policy must
200
+ * be attached to the plugin's own fiber (the entry that owns the Config), not
201
+ * to the injected child.
202
+ */
203
+ function configureOwnPage(ctx, settingsCtx, settings) {
204
+ try {
205
+ const dispose = settings.configure?.({ auto: false }, ctx.fiber);
206
+ if (typeof dispose === 'function') {
207
+ const stop = dispose;
208
+ settingsCtx.effect(() => stop);
209
+ }
210
+ }
211
+ catch (error) {
212
+ // Decorative: the card renders and saves either way; a future
213
+ // auto-generated page would merely duplicate it. `configure` throws when
214
+ // this fiber already registered a policy (a second inject pass).
215
+ settingsCtx.logger.info(`dsh-tui-theme: settings page policy not applied (${String(error)})`);
216
+ }
217
+ }
218
+ /**
219
+ * Follow the loader's live-config announcements.
220
+ *
221
+ * The *listener* registers on the plugin's own context: the loader emits
222
+ * `loader/volatile-update` on the Config-owning fiber and `Context.filter`
223
+ * delivers it to that fiber alone, which is the same reason the host's compat
224
+ * shim passes the Config owner rather than the injected child.
225
+ *
226
+ * Its *disposal*, however, belongs to the inject child (`owner`). This body
227
+ * runs once per settings-service arrival, in a fiber cordis recycles, while the
228
+ * plugin fiber outlives every pass: an effect owned by the plugin would let a
229
+ * second pass stack a second listener on top of the first (each event then
230
+ * re-reading the config twice). Without the event the initial read stands for
231
+ * the session — logged, because that means `/settings` edits would not reach
232
+ * the running plugin until it reloads.
233
+ */
234
+ function watchLiveConfig(ctx, owner, wiring) {
235
+ const refresh = () => {
236
+ wiring.onDoc(definedOnly(wiring.readLive()));
237
+ };
238
+ const events = ctx;
239
+ let dispose;
240
+ try {
241
+ dispose = events.on?.('loader/volatile-update', refresh);
242
+ }
243
+ catch {
244
+ dispose = undefined;
245
+ }
246
+ if (typeof dispose !== 'function') {
247
+ ctx.logger.info('dsh-tui-theme: loader/volatile-update is unavailable; /settings edits apply at the next plugin reload');
248
+ return;
249
+ }
250
+ // `ctx.on` already ties the listener to the plugin fiber (cordis's own
251
+ // effect inside `Context.on`); this one exists for the recycled inject child,
252
+ // so it must run on that child's ledger rather than the plugin's.
253
+ owner.effect(() => dispose);
254
+ }
93
255
  function joinHomeDataDir() {
94
256
  return join(homeDir(), '.dsh-tui');
95
257
  }
@@ -111,9 +273,9 @@ function followCacheDate(at) {
111
273
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
112
274
  }
113
275
  /** The declarative /settings block (labels bilingual, zh via descriptions). */
114
- function sectionDefinition(cordis, dataDir) {
276
+ function sectionDefinition(ns, cordis, dataDir) {
115
277
  return {
116
- ns: PLUGIN_ID,
278
+ ns,
117
279
  title: 'pink-theme',
118
280
  descriptions: { zh: 'pink-theme' },
119
281
  groups: [
@@ -1 +1 @@
1
- {"version":3,"file":"statusLine.d.ts","sourceRoot":"","sources":["../../src/statusLine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAkBlD,mDAAmD;AACnD,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,YAAY,CAAA;AAEpD,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,qEAAqE;IACrE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,sEAAsE;IACtE,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,4CAA4C;IAC5C,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;AAsCrD;;;;;;GAMG;AACH,wBAAgB,gCAAgC,IAAI,IAAI,CAGvD;AAyGD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,GAAG,IAAI,CAgIvF"}
1
+ {"version":3,"file":"statusLine.d.ts","sourceRoot":"","sources":["../../src/statusLine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAkBlD,mDAAmD;AACnD,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,YAAY,CAAA;AAEpD,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,qEAAqE;IACrE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,sEAAsE;IACtE,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,4CAA4C;IAC5C,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;AAsCrD;;;;;;GAMG;AACH,wBAAgB,gCAAgC,IAAI,IAAI,CAGvD;AAuGD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,GAAG,IAAI,CAyIvF"}
@@ -106,8 +106,7 @@ function activeThemeName(dataDir) {
106
106
  }
107
107
  return prefCache.value;
108
108
  }
109
- function isPinkThemeActive(dataDir) {
110
- const name = activeThemeName(dataDir);
109
+ function isPinkTheme(name) {
111
110
  return name !== undefined && PINK_THEMES.has(name);
112
111
  }
113
112
  // ── palette colors ──────────────────────────────────────────────────────────
@@ -151,13 +150,12 @@ function readPalette(name) {
151
150
  }
152
151
  return NO_COLORS;
153
152
  }
154
- function paletteFor(dataDir) {
155
- const name = activeThemeName(dataDir);
156
- if (name === undefined || !PINK_THEMES.has(name))
153
+ function paletteFor(themeName) {
154
+ if (!isPinkTheme(themeName))
157
155
  return NO_COLORS;
158
156
  const now = Date.now();
159
- if (colorsCache === undefined || colorsCache.name !== name || now - colorsCache.at >= THEME_PREF_TTL_MS) {
160
- colorsCache = { at: now, name, colors: readPalette(name) };
157
+ if (colorsCache === undefined || colorsCache.name !== themeName || now - colorsCache.at >= THEME_PREF_TTL_MS) {
158
+ colorsCache = { at: now, name: themeName, colors: readPalette(themeName) };
161
159
  }
162
160
  return colorsCache.colors;
163
161
  }
@@ -192,7 +190,8 @@ export function startStatusLine(ctx, getEffective) {
192
190
  // differ in how the result reaches the host.
193
191
  const renderScalar = () => {
194
192
  const eff = getEffective();
195
- const cells = statusCells(eff);
193
+ const themeName = activeThemeName(dataDir);
194
+ const cells = statusCells(eff, themeName);
196
195
  const separator = sanitizeOrnament(eff.statusSeparator, SEPARATOR);
197
196
  const parts = [cells.glyph, cells.clock, cells.turns].filter((value) => value !== undefined);
198
197
  const text = parts.join(` ${separator} `);
@@ -209,14 +208,18 @@ export function startStatusLine(ctx, getEffective) {
209
208
  try {
210
209
  if (store !== undefined) {
211
210
  const eff = getEffective();
212
- const cells = statusCells(eff);
211
+ // One pref read per render, shared by the visibility check and the
212
+ // palette: the module header's "one cheap stat" invariant holds on
213
+ // both paths (statusCells and paletteFor each used to re-resolve).
214
+ const themeName = activeThemeName(dataDir);
215
+ const cells = statusCells(eff, themeName);
213
216
  store.push({
214
217
  visible: cells.glyph !== undefined || cells.clock !== undefined || cells.turns !== undefined,
215
218
  glyph: cells.glyph,
216
219
  clock: cells.clock,
217
220
  turns: cells.turns,
218
221
  separator: sanitizeOrnament(eff.statusSeparator, SEPARATOR),
219
- colors: paletteFor(dataDir),
222
+ colors: paletteFor(themeName),
220
223
  });
221
224
  return;
222
225
  }
@@ -227,9 +230,10 @@ export function startStatusLine(ctx, getEffective) {
227
230
  }
228
231
  };
229
232
  /** The three optional cells (all undefined = the line is off: master
230
- * switch, theme scope, and the toggles fold into this one shape). */
231
- function statusCells(eff) {
232
- const enabled = eff.statusEnabled && (eff.statusScope === 'all-themes' || isPinkThemeActive(dataDir));
233
+ * switch, theme scope, and the toggles fold into this one shape).
234
+ * Takes the theme name resolved once by the caller per render. */
235
+ function statusCells(eff, themeName) {
236
+ const enabled = eff.statusEnabled && (eff.statusScope === 'all-themes' || isPinkTheme(themeName));
233
237
  if (!enabled)
234
238
  return { glyph: undefined, clock: undefined, turns: undefined };
235
239
  return {
@@ -19,6 +19,10 @@ export interface ThemeInstallResult {
19
19
  readonly repaired: readonly string[];
20
20
  /** Files that could not be installed (per-file failures). */
21
21
  readonly failed: readonly string[];
22
+ /** Set only when the bundled directory itself was unreadable, so nothing
23
+ * was attempted and no per-file entry exists. Carries the directory and
24
+ * the underlying error for the caller's log. */
25
+ readonly sourceError?: string;
22
26
  }
23
27
  export interface BundledTheme {
24
28
  readonly file: string;
@@ -1 +1 @@
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;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,WAAW,CAAA;IAC7C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACxC;AAID,wBAAgB,OAAO,IAAI,MAAM,CAEhC;AAED,2EAA2E;AAC3E,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED,2DAA2D;AAC3D,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,MAA2B,GAAG,YAAY,EAAE,CA0CxF;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,kBAAkB,CAmCpB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,SAAS,MAAM,EAAE,EACxB,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,MAAM,EAAE,CAuBV;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,MAAM,EAAE,CAkBV"}
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;IAClC;;qDAEiD;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAC9B;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,WAAW,CAAA;IAC7C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACxC;AAOD,wBAAgB,OAAO,IAAI,MAAM,CAEhC;AAED,2EAA2E;AAC3E,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED,2DAA2D;AAC3D,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,MAA2B,GAAG,YAAY,EAAE,CA0CxF;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,kBAAkB,CAqCpB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,SAAS,MAAM,EAAE,EACxB,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,MAAM,EAAE,CAuBV;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,MAAM,EAAE,CAkBV"}
@@ -15,7 +15,10 @@ import { homedir } from 'node:os';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  // Same resolution order as the host's utils/paths.ts homeDir(): os.homedir()
18
- // first, USERPROFILE/HOME spellings as stripped-down fallbacks.
18
+ // first, USERPROFILE/HOME spellings as stripped-down fallbacks. When all three
19
+ // come up empty the returned '' makes every derived path resolve against the
20
+ // process cwd — the host's own semantics, kept deliberately identical rather
21
+ // than second-guessed here.
19
22
  export function homeDir() {
20
23
  return homedir() || process.env.USERPROFILE || process.env.HOME || '';
21
24
  }
@@ -87,8 +90,10 @@ export function installBundledThemes(targetDir = themesTargetDir(), sourceDir =
87
90
  try {
88
91
  files = readdirSync(sourceDir).filter(entry => entry.toLowerCase().endsWith('.json'));
89
92
  }
90
- catch {
91
- return { installed, skipped, repaired, failed: [sourceDir] };
93
+ catch (error) {
94
+ // No file was attempted, so `failed` stays empty; the directory-level
95
+ // reason travels separately so the caller can log a precise line.
96
+ return { installed, skipped, repaired, failed, sourceError: `could not read bundled themes from ${sourceDir}: ${String(error)}` };
92
97
  }
93
98
  for (const file of files) {
94
99
  const target = join(targetDir, file);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tui-theme",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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",
@@ -28,8 +28,9 @@
28
28
  "preverify": "npm run build",
29
29
  "verify": "node scripts/verify.mjs",
30
30
  "verify:package": "node scripts/verify-package.mjs",
31
+ "verify:settings": "node scripts/verify-settings-generation.mjs",
31
32
  "verify:host": "npm run build && node scripts/headless-order-test.mjs && node scripts/runtime-themes-headless.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
+ "release:check": "npm run build && npm run verify && npm run verify:package && npm run verify:settings",
33
34
  "prepack": "npm run release:check"
34
35
  },
35
36
  "license": "MIT",
@@ -58,8 +59,8 @@
58
59
  },
59
60
  "peerDependencies": {
60
61
  "@deepseek-ai/cordis": "^4.0.1",
61
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || 0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.2-alpha.3 || 0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1",
62
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || 0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.2-alpha.3 || 0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1",
62
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || 0.1.2-alpha.3 || 0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1",
63
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || 0.1.2-alpha.3 || 0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1",
63
64
  "@deepseek-ai/schemastery": "^3.18.1"
64
65
  },
65
66
  "devDependencies": {