dsh-tui-theme 0.5.1 → 0.6.1

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.
@@ -10,7 +10,7 @@
10
10
  * contained per file: a theme garnish must never break the TUI's boot.
11
11
  * @module dsh-tui-theme/themeAssets
12
12
  */
13
- import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
13
+ import { mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
@@ -27,6 +27,51 @@ export function bundledThemesDir() {
27
27
  export function themesTargetDir() {
28
28
  return join(homeDir(), '.dsh-tui', 'themes');
29
29
  }
30
+ /** Read bundled descriptors for hosts that support runtime theme registration. */
31
+ export function readBundledThemes(sourceDir = bundledThemesDir()) {
32
+ let files;
33
+ try {
34
+ files = readdirSync(sourceDir).filter(entry => entry.toLowerCase().endsWith('.json'));
35
+ }
36
+ catch (error) {
37
+ console.warn(`dsh-tui-theme: could not read bundled themes from ${sourceDir}: ${String(error)}`);
38
+ return [];
39
+ }
40
+ const themes = [];
41
+ for (const file of files) {
42
+ try {
43
+ const value = JSON.parse(readFileSync(join(sourceDir, file), 'utf8'));
44
+ const name = value.name;
45
+ const base = value.base;
46
+ const colors = value.colors;
47
+ if (typeof name !== 'string' ||
48
+ !/^[a-z][a-z0-9_-]*(?::[a-z][a-z0-9_-]*)?$/u.test(name) ||
49
+ (base !== 'light' && base !== 'dark' && base !== 'dark-ansi') ||
50
+ colors === null ||
51
+ typeof colors !== 'object' ||
52
+ Array.isArray(colors)) {
53
+ throw new Error('invalid theme descriptor');
54
+ }
55
+ const normalizedColors = {};
56
+ for (const [key, color] of Object.entries(colors)) {
57
+ if (typeof color !== 'string')
58
+ throw new Error(`invalid color for ${key}`);
59
+ normalizedColors[key] = color;
60
+ }
61
+ themes.push({
62
+ file,
63
+ name,
64
+ ...(typeof value.displayName === 'string' ? { displayName: value.displayName } : {}),
65
+ base,
66
+ colors: normalizedColors,
67
+ });
68
+ }
69
+ catch (error) {
70
+ console.warn(`dsh-tui-theme: skipping bundled theme ${file}: ${String(error)}`);
71
+ }
72
+ }
73
+ return themes;
74
+ }
30
75
  /**
31
76
  * Install every bundled theme JSON that the target directory is missing.
32
77
  * @param targetDir - Destination directory (defaults to ~/.dsh-tui/themes).
@@ -57,8 +102,11 @@ export function installBundledThemes(targetDir = themesTargetDir(), sourceDir =
57
102
  }
58
103
  catch (error) {
59
104
  if (error.code === 'EEXIST') {
60
- if (healCorruptTarget(target, text))
105
+ const healed = healCorruptTarget(target, text);
106
+ if (healed === 'repaired')
61
107
  repaired.push(file);
108
+ else if (healed === 'failed')
109
+ failed.push(file);
62
110
  else
63
111
  skipped.push(file);
64
112
  }
@@ -73,14 +121,73 @@ export function installBundledThemes(targetDir = themesTargetDir(), sourceDir =
73
121
  }
74
122
  return { installed, skipped, repaired, failed };
75
123
  }
124
+ /**
125
+ * Remove files that this activation installed when a runtime theme service
126
+ * becomes available. A byte-for-byte check preserves edits made in the small
127
+ * window between installation and runtime confirmation.
128
+ */
129
+ export function removeBundledThemes(files, targetDir = themesTargetDir(), sourceDir = bundledThemesDir()) {
130
+ const removed = [];
131
+ for (const file of files) {
132
+ try {
133
+ const bundled = readFileSync(join(sourceDir, file), 'utf8');
134
+ const target = join(targetDir, file);
135
+ if (readFileSync(target, 'utf8') !== bundled)
136
+ continue;
137
+ unlinkSync(target);
138
+ removed.push(file);
139
+ }
140
+ catch {
141
+ // Runtime registration must remain best-effort if a file disappears or
142
+ // the target directory becomes unavailable during teardown.
143
+ }
144
+ }
145
+ if (removed.length > 0) {
146
+ try {
147
+ if (readdirSync(targetDir).length === 0)
148
+ rmdirSync(targetDir);
149
+ }
150
+ catch {
151
+ // An empty directory is only a cosmetic artifact; leave it in place if
152
+ // it was removed concurrently or is no longer empty.
153
+ }
154
+ }
155
+ return removed;
156
+ }
157
+ /**
158
+ * Bundled theme files present in the target directory byte-for-byte
159
+ * identical to the bundled copy. On runtime-theme hosts such files shadow
160
+ * the registry while adding nothing, so the entry point can point them out
161
+ * once. User-edited or foreign same-named files are never reported — the
162
+ * never-overwrite rule keeps them, and no toast may nag about them.
163
+ */
164
+ export function findShadowedBundledThemes(targetDir = themesTargetDir(), sourceDir = bundledThemesDir()) {
165
+ const shadowed = [];
166
+ let files;
167
+ try {
168
+ files = readdirSync(sourceDir).filter(entry => entry.toLowerCase().endsWith('.json'));
169
+ }
170
+ catch {
171
+ return shadowed;
172
+ }
173
+ for (const file of files) {
174
+ try {
175
+ if (readFileSync(join(targetDir, file), 'utf8') === readFileSync(join(sourceDir, file), 'utf8')) {
176
+ shadowed.push(file);
177
+ }
178
+ }
179
+ catch {
180
+ // Missing or unreadable target: not a shadow.
181
+ }
182
+ }
183
+ return shadowed;
184
+ }
76
185
  /**
77
186
  * Self-heal an existing target that fails to parse as JSON — the leftover of
78
187
  * 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).
188
+ * <target>.corrupt-<timestamp>-<pid>-<random> and the bundled copy installed
189
+ * fresh. Returns `skipped` when the target is valid JSON or unreadable, and
190
+ * `failed` when the corrupt target was moved but replacement could not finish.
84
191
  */
85
192
  function healCorruptTarget(target, text) {
86
193
  let existing;
@@ -88,21 +195,28 @@ function healCorruptTarget(target, text) {
88
195
  existing = readFileSync(target, 'utf8');
89
196
  }
90
197
  catch {
91
- return false;
198
+ return 'skipped';
92
199
  }
93
200
  try {
94
201
  JSON.parse(existing);
95
- return false;
202
+ return 'skipped';
96
203
  }
97
204
  catch {
98
205
  // Proven corrupt: fall through to backup and reinstall.
99
206
  }
207
+ let backup;
208
+ try {
209
+ backup = `${target}.corrupt-${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`;
210
+ renameSync(target, backup);
211
+ }
212
+ catch {
213
+ return 'skipped';
214
+ }
100
215
  try {
101
- renameSync(target, `${target}.corrupt-${Date.now()}`);
102
216
  writeFileSync(target, text, { flag: 'wx' });
103
- return true;
217
+ return 'repaired';
104
218
  }
105
219
  catch {
106
- return false;
220
+ return 'failed';
107
221
  }
108
222
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Toast relay for the plugin-facing transient-notification seam
3
+ * (dsh-TUI 0.10: `ctx.tuiToast`).
4
+ *
5
+ * The plugin's own logger output is invisible to a TUI user, so the few
6
+ * events worth surfacing — a follow-system pref write, a repaired theme
7
+ * file, a shadowing legacy static install — go through the host's toast
8
+ * surface instead. Sending is fire-and-forget: hosts without the seam
9
+ * (dsh-TUI < 0.10) never fire the inject and every send is a silent no-op,
10
+ * exactly like the other soft-probed seams.
11
+ *
12
+ * The host registers its toast sink late (the dsh-tui row applies after the
13
+ * extensions row, and both may trail this plugin), so a toast dropped for
14
+ * having no sink is retried on a short schedule before giving up. The
15
+ * tuiToast service itself arrives with the extensions row, so a send fired
16
+ * before the seam even exists waits on the same schedule. Retries are
17
+ * bounded, unref'd, and cleared with the activation; a toast lost to a host
18
+ * rate limit is not worth fighting — the next real event re-reports.
19
+ * @module dsh-tui-theme/toast
20
+ */
21
+ import type { Context } from '@deepseek-ai/cordis';
22
+ /** Host toast color vocabulary (neutral when omitted). */
23
+ export type ToastColor = 'success' | 'warning' | 'error';
24
+ /** Send one transient notification; resolves to whether it was delivered. */
25
+ export type ToastSend = (text: string, color?: ToastColor) => boolean;
26
+ /**
27
+ * @internal Shrink the retry schedule for hermetic tests (verify.mjs only;
28
+ * not part of the plugin's behavioral contract).
29
+ */
30
+ export declare function setToastRetryDelaysForTests(delays: readonly number[]): void;
31
+ /**
32
+ * Start the toast relay. Returns a sender that is callable immediately and
33
+ * from any later context (settings watches, timers): `show()` binds through
34
+ * the service's own activation, so unlike `tuiStatus.set` it takes no
35
+ * identity argument — but the service object itself must still be read
36
+ * inside the inject (same rule as every other seam).
37
+ * @param ctx - The plugin's own activation context.
38
+ */
39
+ export declare function startToastRelay(ctx: Context): ToastSend;
40
+ //# sourceMappingURL=toast.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"toast.d.ts","sourceRoot":"","sources":["../../src/toast.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,0DAA0D;AAC1D,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAA;AAUxD,6EAA6E;AAC7E,MAAM,MAAM,SAAS,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,UAAU,KAAK,OAAO,CAAA;AAMrE;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAE3E;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,CAoDvD"}
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Toast relay for the plugin-facing transient-notification seam
3
+ * (dsh-TUI 0.10: `ctx.tuiToast`).
4
+ *
5
+ * The plugin's own logger output is invisible to a TUI user, so the few
6
+ * events worth surfacing — a follow-system pref write, a repaired theme
7
+ * file, a shadowing legacy static install — go through the host's toast
8
+ * surface instead. Sending is fire-and-forget: hosts without the seam
9
+ * (dsh-TUI < 0.10) never fire the inject and every send is a silent no-op,
10
+ * exactly like the other soft-probed seams.
11
+ *
12
+ * The host registers its toast sink late (the dsh-tui row applies after the
13
+ * extensions row, and both may trail this plugin), so a toast dropped for
14
+ * having no sink is retried on a short schedule before giving up. The
15
+ * tuiToast service itself arrives with the extensions row, so a send fired
16
+ * before the seam even exists waits on the same schedule. Retries are
17
+ * bounded, unref'd, and cleared with the activation; a toast lost to a host
18
+ * rate limit is not worth fighting — the next real event re-reports.
19
+ * @module dsh-tui-theme/toast
20
+ */
21
+ import { AsyncResource } from 'node:async_hooks';
22
+ /** Drop-retry schedule (ms). The first delivery attempt is always immediate. */
23
+ const RETRY_DELAYS_MS = [2_000, 4_000];
24
+ let retryDelays = RETRY_DELAYS_MS;
25
+ /**
26
+ * @internal Shrink the retry schedule for hermetic tests (verify.mjs only;
27
+ * not part of the plugin's behavioral contract).
28
+ */
29
+ export function setToastRetryDelaysForTests(delays) {
30
+ retryDelays = delays;
31
+ }
32
+ /**
33
+ * Start the toast relay. Returns a sender that is callable immediately and
34
+ * from any later context (settings watches, timers): `show()` binds through
35
+ * the service's own activation, so unlike `tuiStatus.set` it takes no
36
+ * identity argument — but the service object itself must still be read
37
+ * inside the inject (same rule as every other seam).
38
+ * @param ctx - The plugin's own activation context.
39
+ */
40
+ export function startToastRelay(ctx) {
41
+ const pending = new Set();
42
+ let show;
43
+ const clearPending = () => {
44
+ for (const timer of pending)
45
+ clearTimeout(timer);
46
+ pending.clear();
47
+ };
48
+ ctx.inject(['tuiToast'], toastCtx => {
49
+ const toast = toastCtx.tuiToast;
50
+ // show() resolves its caller through the ambient Cordis activation, so a
51
+ // call from a foreign async scope (a settings watch, a host timer of
52
+ // another seam) is refused even though this plugin is alive. Capture the
53
+ // activation scope here — inside the inject, where it is ambient — and
54
+ // re-enter it for every send, exactly like the inject-created interval
55
+ // that keeps the status line rendering.
56
+ const scope = new AsyncResource('dsh-tui-theme-toast');
57
+ show = (text, options) => {
58
+ try {
59
+ return scope.runInAsyncScope(() => toast.show(text, options));
60
+ }
61
+ catch {
62
+ // A hostile or tearing-down host must never propagate into the caller.
63
+ return false;
64
+ }
65
+ };
66
+ toastCtx.effect(() => () => {
67
+ show = undefined;
68
+ clearPending();
69
+ });
70
+ });
71
+ ctx.effect(() => () => {
72
+ show = undefined;
73
+ clearPending();
74
+ });
75
+ const attempt = (text, color, depth) => {
76
+ // A seam that has not arrived yet (the extensions row applies after this
77
+ // plugin) retries on the same bounded schedule as a dropped send.
78
+ if (show !== undefined && show(text, color === undefined ? {} : { color }))
79
+ return true;
80
+ if (depth >= retryDelays.length)
81
+ return false;
82
+ const timer = setTimeout(() => {
83
+ pending.delete(timer);
84
+ attempt(text, color, depth + 1);
85
+ }, retryDelays[depth]);
86
+ timer.unref?.();
87
+ pending.add(timer);
88
+ return false;
89
+ };
90
+ return (text, color) => attempt(text, color, 0);
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tui-theme",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
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,7 +28,7 @@
28
28
  "preverify": "npm run build",
29
29
  "verify": "node scripts/verify.mjs",
30
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",
31
+ "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
32
  "release:check": "npm run build && npm run verify && npm run verify:package",
33
33
  "prepack": "npm run release:check"
34
34
  },
@@ -64,11 +64,53 @@
64
64
  },
65
65
  "devDependencies": {
66
66
  "@deepseek-ai/cordis": "^4.0.1",
67
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
68
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
67
+ "@deepseek-ai/dsh-session": "0.1.2-alpha.2",
68
+ "@deepseek-ai/dsh-settings": "0.1.2-alpha.2",
69
69
  "@deepseek-ai/schemastery": "^3.18.1",
70
+ "@deepseek-harness-tui/dsh-tui": "0.10.0-beta.4",
70
71
  "@types/node": "^22.0.0",
71
72
  "tsx": "^4.23.12",
72
- "typescript": "^6.0.3"
73
+ "typescript": "^6.0.3",
74
+ "@deepseek-ai/dsh-llm": "0.1.2-alpha.2",
75
+ "@deepseek-ai/dsh-agent": "0.1.2-alpha.2",
76
+ "@deepseek-ai/dsh-user-questions": "0.1.2-alpha.2",
77
+ "@deepseek-ai/dsh-user-approval": "0.1.2-alpha.2",
78
+ "@deepseek-ai/dsh-commands": "0.1.2-alpha.2",
79
+ "@deepseek-ai/dsh-atomic-write": "0.1.2-alpha.2",
80
+ "@deepseek-ai/dsh-tool-ask-user": "0.1.2-alpha.2",
81
+ "@deepseek-ai/dsh-skill": "0.1.2-alpha.2",
82
+ "@deepseek-ai/dsh-agent-instructions": "0.1.2-alpha.2",
83
+ "@deepseek-ai/dsh-tools": "0.1.2-alpha.2",
84
+ "@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.2",
85
+ "@deepseek-ai/dsh-invariants": "0.1.2-alpha.2"
86
+ },
87
+ "overrides": {
88
+ "@deepseek-ai/dsh-session": "0.1.2-alpha.2",
89
+ "@deepseek-ai/dsh-settings": "0.1.2-alpha.2",
90
+ "@deepseek-ai/dsh-llm": "0.1.2-alpha.2",
91
+ "@deepseek-ai/dsh-agent": "0.1.2-alpha.2",
92
+ "@deepseek-ai/dsh-user-questions": "0.1.2-alpha.2",
93
+ "@deepseek-ai/dsh-user-approval": "0.1.2-alpha.2",
94
+ "@deepseek-ai/dsh-commands": "0.1.2-alpha.2",
95
+ "@deepseek-ai/dsh-atomic-write": "0.1.2-alpha.2",
96
+ "@deepseek-ai/dsh-tool-ask-user": "0.1.2-alpha.2",
97
+ "@deepseek-ai/dsh-skill": "0.1.2-alpha.2",
98
+ "@deepseek-ai/dsh-agent-instructions": "0.1.2-alpha.2",
99
+ "@deepseek-ai/dsh-tools": "0.1.2-alpha.2",
100
+ "@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.2",
101
+ "@deepseek-ai/dsh-invariants": "0.1.2-alpha.2",
102
+ "@deepseek-ai/dsh-scope": "0.1.2-alpha.2",
103
+ "@deepseek-ai/dsh-attachment": "0.1.2-alpha.2",
104
+ "@deepseek-ai/dsh-brand": "0.1.2-alpha.2",
105
+ "@deepseek-ai/dsh-typert-protocol": "0.1.2-alpha.2",
106
+ "@deepseek-ai/dsh-timeout": "0.1.2-alpha.2",
107
+ "@deepseek-ai/dsh-session-projection": "0.1.2-alpha.2",
108
+ "@deepseek-ai/dsh-code-runtime": "0.1.2-alpha.2",
109
+ "@deepseek-ai/dsh-fs": "0.1.2-alpha.2",
110
+ "@deepseek-ai/dsh-home-paths": "0.1.2-alpha.2",
111
+ "@deepseek-ai/dsh-sandbox": "0.1.2-alpha.2"
112
+ },
113
+ "allowScripts": {
114
+ "esbuild@0.28.2": true
73
115
  }
74
116
  }
@@ -2,7 +2,8 @@
2
2
  * End-to-end order test with the installed dsh-TUI services. Composes this
3
3
  * plugin before the extension services to exercise late service injection.
4
4
  *
5
- * DSH_TUI_ADAPTER_DIR must point at dsh-TUI's lib/types/dsh-adapter directory.
5
+ * DSH_TUI_ADAPTER_DIR may point at dsh-TUI's lib/types/dsh-adapter directory.
6
+ * When omitted, the installed devDependency is used.
6
7
  * Script-side floor: the adapter must be a built dsh-TUI >= 0.9.0 — the
7
8
  * settings-sections module and its getHostSettingsSections probe landed there.
8
9
  * Set DSH_TUI_EXPECTED_VERSION only when an explicit release baseline needs
@@ -10,7 +11,7 @@
10
11
  */
11
12
  import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
13
  import { tmpdir } from 'node:os'
13
- import { join } from 'node:path'
14
+ import { dirname, join } from 'node:path'
14
15
  import { fileURLToPath, pathToFileURL } from 'node:url'
15
16
  import { createRequire } from 'node:module'
16
17
  import assert from 'node:assert/strict'
@@ -23,10 +24,10 @@ import { assertSettingsContract, SETTINGS_NAMESPACE } from './expected-settings-
23
24
  const STATUS_CONTRIBUTION_KEY = SETTINGS_NAMESPACE
24
25
 
25
26
  const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
26
- const adapter = process.env.DSH_TUI_ADAPTER_DIR
27
- if (adapter === undefined || adapter === '') {
28
- throw new Error('DSH_TUI_ADAPTER_DIR must point at dsh-TUI lib/types/dsh-adapter for this host integration test.')
29
- }
27
+ const req = createRequire(import.meta.url)
28
+ const packageJson = req.resolve('@deepseek-harness-tui/dsh-tui/package.json')
29
+ const devHostRoot = dirname(packageJson)
30
+ const adapter = process.env.DSH_TUI_ADAPTER_DIR || join(devHostRoot, 'lib', 'types', 'dsh-adapter')
30
31
 
31
32
  if (!existsSync(join(adapter, 'extensions.js'))) {
32
33
  throw new Error(`dsh-TUI adapter not found at ${adapter}`)
@@ -63,17 +64,28 @@ if (!existsSync(settingsSectionsPath)) {
63
64
  )
64
65
  }
65
66
 
66
- const req = createRequire(join(adapter, 'extensions.js'))
67
- const { Context } = await import(pathToFileURL(req.resolve('@deepseek-ai/cordis')).href)
67
+ const hostRequire = createRequire(join(adapter, 'extensions.js'))
68
+ const { Context } = await import(pathToFileURL(hostRequire.resolve('@deepseek-ai/cordis')).href)
68
69
  const extensions = await import(pathToFileURL(join(adapter, 'extensions.js')).href)
69
- const ledgerModule = await import(pathToFileURL(join(adapter, 'effect-ledger.js')).href)
70
70
  const statusModule = await import(pathToFileURL(join(adapter, 'status.js')).href)
71
71
  const settingsSectionsModule = await import(pathToFileURL(settingsSectionsPath).href)
72
+ const pluginHostModule = await import(pathToFileURL(join(adapter, 'plugin-host.js')).href)
72
73
  const pink = await import(pathToFileURL(join(pluginRoot, 'lib', 'types', 'index.js')).href)
73
74
 
74
75
  const app = new Context()
75
- await app.plugin(ledgerModule.default)
76
- await app.plugin(pink)
76
+ await app.plugin(pluginHostModule.default ?? pluginHostModule)
77
+ const testUtilsPath = join(adapter, '..', 'test-utils.js')
78
+ if (existsSync(testUtilsPath)) {
79
+ const testUtils = await import(pathToFileURL(testUtilsPath).href)
80
+ const manifest = testUtils.testManifest({ id: SETTINGS_NAMESPACE })
81
+ const admitted = await testUtils.mountAdmitted(app, SETTINGS_NAMESPACE, manifest)
82
+ await admitted.context.plugin(pink)
83
+ } else {
84
+ // dsh-TUI < 0.10 has no public admission test helpers; keep the historical
85
+ // manual mount and report the structural limitation instead of hiding it.
86
+ console.log('* mountAdmitted unavailable on this host; using manual plugin mount')
87
+ await app.plugin(pink)
88
+ }
77
89
  await app.plugin(extensions.default ?? extensions)
78
90
  await app.plugin(settingsSectionsModule.default ?? settingsSectionsModule)
79
91
 
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Dev-only integration check for dsh-TUI 0.10 runtime theme registration.
3
+ * Uses the installed host adapter and the real admission path; no user HOME
4
+ * or static theme files are touched.
5
+ */
6
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { createRequire } from 'node:module'
8
+ import { tmpdir } from 'node:os'
9
+ import { dirname, join } 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 req = createRequire(import.meta.url)
15
+ const hostPackagePath = req.resolve('@deepseek-harness-tui/dsh-tui/package.json')
16
+ const hostRoot = dirname(hostPackagePath)
17
+ const adapter = process.env.DSH_TUI_ADAPTER_DIR || join(hostRoot, 'lib', 'types', 'dsh-adapter')
18
+ if (!existsSync(join(adapter, 'extensions.js'))) {
19
+ throw new Error(`dsh-TUI runtime adapter not found at ${adapter}`)
20
+ }
21
+ if (!existsSync(join(adapter, 'themes.js'))) {
22
+ console.log('* runtime theme seam unavailable on this host; skipping 0.10 headless check')
23
+ process.exit(0)
24
+ }
25
+
26
+ const sandbox = mkdtempSync(join(tmpdir(), 'pink-runtime-themes-'))
27
+ process.env.USERPROFILE = sandbox
28
+ process.env.HOME = sandbox
29
+ const dataDir = join(sandbox, '.dsh-tui')
30
+ const staticThemes = join(dataDir, 'themes')
31
+ mkdirSync(dataDir, { recursive: true })
32
+ writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
33
+
34
+ const hostRequire = createRequire(join(adapter, 'extensions.js'))
35
+ const { Context } = await import(pathToFileURL(hostRequire.resolve('@deepseek-ai/cordis')).href)
36
+ const pluginHost = await import(pathToFileURL(join(adapter, 'plugin-host.js')).href)
37
+ const extensions = await import(pathToFileURL(join(adapter, 'extensions.js')).href)
38
+ const themesModule = await import(pathToFileURL(join(adapter, 'themes.js')).href)
39
+ const themeModule = await import(pathToFileURL(join(adapter, '..', 'theme.js')).href)
40
+ const testUtils = await import(pathToFileURL(join(adapter, '..', 'test-utils.js')).href)
41
+ const pink = await import(pathToFileURL(join(pluginRoot, 'lib', 'types', 'index.js')).href)
42
+ const { SETTINGS_NAMESPACE } = await import(pathToFileURL(join(pluginRoot, 'scripts', 'expected-settings-contract.mjs')).href)
43
+
44
+ const app = new Context()
45
+ await app.plugin(pluginHost.default ?? pluginHost)
46
+ const manifest = testUtils.testManifest({ id: SETTINGS_NAMESPACE })
47
+ const admitted = await testUtils.mountAdmitted(app, SETTINGS_NAMESPACE, manifest)
48
+ await admitted.context.plugin(pink)
49
+ await app.plugin(extensions.default ?? extensions)
50
+
51
+ const host = themesModule.getHostThemes(app.get('tuiThemes'))
52
+ assert.ok(host, 'runtime theme host must be mounted')
53
+ const deadline = Date.now() + 5_000
54
+ while (host.getSnapshot().length !== 3 && Date.now() < deadline) {
55
+ await testUtils.sleep(25)
56
+ }
57
+ const snapshot = host.getSnapshot()
58
+ assert.deepEqual(snapshot.map(entry => entry.name).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
59
+
60
+ for (const entry of snapshot) {
61
+ const expected = JSON.parse(readFileSync(join(pluginRoot, 'themes', `${entry.name}.json`), 'utf8'))
62
+ assert.equal(entry.displayName, expected.displayName)
63
+ assert.equal(entry.base, expected.base)
64
+ assert.deepEqual(entry.colors, expected.colors)
65
+ assert.deepEqual(host.resolve(entry.name), { ...themeModule.getTheme(entry.base), ...expected.colors })
66
+ assert.equal(themeModule.getTheme(entry.name).claude, expected.colors.claude)
67
+ }
68
+ assert.equal(existsSync(staticThemes), false, 'runtime registration must not create static theme files')
69
+ await testUtils.sleep(1_700)
70
+ assert.equal(existsSync(staticThemes), false, 'runtime confirmation must leave no static fallback files')
71
+
72
+ const ledgerPath = join(dataDir, 'effect-ledger.jsonl')
73
+ const readLedger = () => (existsSync(ledgerPath) ? readFileSync(ledgerPath, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) : [])
74
+ const themeCreates = readLedger().filter(entry => entry.resource?.kind === 'theme' && entry.operation === 'create')
75
+ assert.deepEqual(themeCreates.map(entry => entry.resource.id).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
76
+
77
+ await admitted.fiber.dispose()
78
+ await testUtils.sleep(50)
79
+ assert.deepEqual(host.getSnapshot(), [], 'disposing the admitted plugin must release runtime themes')
80
+ const themeReleases = readLedger().filter(entry => entry.resource?.kind === 'theme' && entry.operation === 'release')
81
+ assert.deepEqual(themeReleases.map(entry => entry.resource.id).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
82
+ assert.deepEqual(themeModule.getTheme('pink-night'), themeModule.getTheme('dark'), 'disposed runtime theme no longer resolves')
83
+ console.log('OK runtime themes: 3 registered, no static files, ledger create/release, disposal clean')
84
+
85
+ // ── Phase 2: real-host toast delivery (0.10 tuiToast seam) ──────────────────
86
+ // A pre-0.10 user's byte-identical static files shadow the runtime registry;
87
+ // the plugin must surface that through the real toast service. The sink is
88
+ // attached after the extensions row applies, so a toast fired earlier is
89
+ // dropped and must arrive through the relay's bounded retry instead — this
90
+ // exercises both the show() caller binding outside the inject callback and
91
+ // the drop-retry path against the real host.
92
+ const toastModule = await import(pathToFileURL(join(adapter, 'toast.js')).href)
93
+ const sandbox2 = mkdtempSync(join(tmpdir(), 'pink-toast-'))
94
+ process.env.USERPROFILE = sandbox2
95
+ process.env.HOME = sandbox2
96
+ const dataDir2 = join(sandbox2, '.dsh-tui')
97
+ mkdirSync(join(dataDir2, 'themes'), { recursive: true })
98
+ writeFileSync(join(dataDir2, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
99
+ for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
100
+ writeFileSync(
101
+ join(dataDir2, 'themes', `${theme}.json`),
102
+ readFileSync(join(pluginRoot, 'themes', `${theme}.json`), 'utf8'),
103
+ )
104
+ }
105
+
106
+ const deliveries = []
107
+ const app2 = new Context()
108
+ await app2.plugin(pluginHost.default ?? pluginHost)
109
+ const admitted2 = await testUtils.mountAdmitted(app2, SETTINGS_NAMESPACE, manifest)
110
+ await admitted2.context.plugin(pink)
111
+ await app2.plugin(extensions.default ?? extensions)
112
+ toastModule.getHostToastStore(app2.get('tuiToast'))?.setSink(delivery => deliveries.push(delivery))
113
+
114
+ const toastDeadline = Date.now() + 8_000
115
+ while (deliveries.length === 0 && Date.now() < toastDeadline) {
116
+ await testUtils.sleep(25)
117
+ }
118
+ assert.ok(deliveries.length >= 1, 'the shadow-hint toast must reach the host sink (retry included)')
119
+ assert.equal(deliveries[0].color, undefined, 'the shadow hint is neutral')
120
+ for (const file of ['pink-night.json', 'pink-day.json', 'pink-ansi.json']) {
121
+ assert.ok(deliveries[0].text.includes(file), `hint must name ${file}`)
122
+ }
123
+ await admitted2.fiber.dispose()
124
+ await testUtils.sleep(50)
125
+ console.log('OK toast: shadow hint delivered through the real tuiToast seam')
126
+
127
+ // ── Phase 3: apply-time toasts reach the sink through the seam-late retry ───
128
+ // On a real 0.10 host the tuiToast service arrives with the extensions row,
129
+ // after this plugin's apply, so toasts fired during apply (the self-heal
130
+ // warning, the boot-follow result) can only survive through the relay's
131
+ // bounded retry. A settings service present at apply time (the dsh CLI core
132
+ // service) drives the boot-follow path with a disagreeing cache.
133
+ const sandbox3 = mkdtempSync(join(tmpdir(), 'pink-apply-toast-'))
134
+ process.env.USERPROFILE = sandbox3
135
+ process.env.HOME = sandbox3
136
+ const dataDir3 = join(sandbox3, '.dsh-tui')
137
+ mkdirSync(join(dataDir3, 'themes'), { recursive: true })
138
+ // Corrupt self-heal target; boot-follow cache disagrees with the persisted pref.
139
+ writeFileSync(join(dataDir3, 'themes', 'pink-night.json'), '{ this is not json')
140
+ writeFileSync(join(dataDir3, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
141
+ writeFileSync(join(dataDir3, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }, null, 2))
142
+
143
+ // Minimal settings service standing in for the dsh CLI core service: present
144
+ // at plugin apply time with followSystem already enabled, mirrors scope.watch.
145
+ const fakeSettings = {
146
+ register() {
147
+ return {
148
+ get: () => ({ followSystem: true }),
149
+ watch(listener) {
150
+ listener({ followSystem: true })
151
+ return () => {}
152
+ },
153
+ }
154
+ },
155
+ }
156
+
157
+ const deliveries3 = []
158
+ const app3 = new Context()
159
+ await app3.plugin(pluginHost.default ?? pluginHost)
160
+ const admitted3 = await testUtils.mountAdmitted(app3, SETTINGS_NAMESPACE, manifest)
161
+ app3.provide('settings', fakeSettings)
162
+ await admitted3.context.plugin(pink)
163
+ await app3.plugin(extensions.default ?? extensions)
164
+ toastModule.getHostToastStore(app3.get('tuiToast'))?.setSink(delivery => deliveries3.push(delivery))
165
+
166
+ const applyToastDeadline = Date.now() + 8_000
167
+ while (deliveries3.length < 2 && Date.now() < applyToastDeadline) {
168
+ await testUtils.sleep(25)
169
+ }
170
+ const healToast = deliveries3.find(delivery => delivery.text.includes('已修复'))
171
+ assert.ok(healToast, 'the self-heal warning fired during apply must reach the host sink')
172
+ assert.equal(healToast.color, 'warning', 'the self-heal toast is a warning')
173
+ assert.ok(healToast.text.includes('pink-night.json'), 'the heal toast names the repaired file')
174
+ const followToast = deliveries3.find(delivery => delivery.text.includes('已按保存的终端背景'))
175
+ assert.ok(followToast, 'the boot-follow result fired at settings time must reach the host sink')
176
+ assert.equal(followToast.color, 'success', 'the boot-follow toast is a success')
177
+ assert.ok(followToast.text.includes('pink-day') && followToast.text.includes('reload'), 'the follow toast names the new theme and the reload hint')
178
+ assert.equal(JSON.parse(readFileSync(join(dataDir3, 'theme.json'), 'utf8')).theme, 'pink-day', 'the follow pref write still happened')
179
+ await admitted3.fiber.dispose()
180
+ console.log('OK toast phase 3: apply-time self-heal and boot-follow toasts delivered on the real host')