dsh-tui-theme 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -6
- package/lib/types/autoTheme.d.ts +14 -1
- package/lib/types/autoTheme.d.ts.map +1 -1
- package/lib/types/autoTheme.js +10 -5
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +83 -8
- package/lib/types/runtimeThemes.d.ts +9 -0
- package/lib/types/runtimeThemes.d.ts.map +1 -0
- package/lib/types/runtimeThemes.js +39 -0
- package/lib/types/settingsSection.d.ts.map +1 -1
- package/lib/types/settingsSection.js +5 -2
- package/lib/types/themeAssets.d.ts +23 -0
- package/lib/types/themeAssets.d.ts.map +1 -1
- package/lib/types/themeAssets.js +126 -12
- package/lib/types/toast.d.ts +40 -0
- package/lib/types/toast.d.ts.map +1 -0
- package/lib/types/toast.js +91 -0
- package/package.json +49 -7
- package/scripts/headless-order-test.mjs +23 -11
- package/scripts/runtime-themes-headless.mjs +180 -0
- package/scripts/validate-themes-against-host.mjs +22 -14
- package/scripts/verify-package.mjs +14 -1
- package/scripts/verify.mjs +314 -18
|
@@ -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.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Sakura-pink themes for dsh-TUI with optional cached background follow (pink-day/pink-night), a blossom status line, and a /settings section. No shortcuts, no commands.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/types/index.js",
|
|
@@ -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
|
},
|
|
@@ -58,17 +58,59 @@
|
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
61
|
-
"@deepseek-ai/dsh-session": "^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",
|
|
61
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
|
|
62
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
|
|
63
63
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
67
|
-
"@deepseek-ai/dsh-session": "
|
|
68
|
-
"@deepseek-ai/dsh-settings": "
|
|
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
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
67
|
-
const { Context } = await import(pathToFileURL(
|
|
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(
|
|
76
|
-
|
|
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')
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Validate bundled themes against matching dsh-TUI source and runtime modules.
|
|
3
|
-
* DSH_TUI_SOURCE_ROOT
|
|
4
|
-
*
|
|
3
|
+
* DSH_TUI_SOURCE_ROOT and DSH_TUI_ADAPTER_DIR may point at a matching source
|
|
4
|
+
* checkout and adapter directory. When omitted, the installed dsh-TUI
|
|
5
|
+
* devDependency is used as a zero-configuration baseline.
|
|
5
6
|
* Set DSH_TUI_EXPECTED_VERSION only when an explicit release baseline needs
|
|
6
7
|
* to be pinned; ordinary development verifies the supplied host as-is.
|
|
7
8
|
*
|
|
@@ -9,25 +10,32 @@
|
|
|
9
10
|
*/
|
|
10
11
|
import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs'
|
|
11
12
|
import { tmpdir } from 'node:os'
|
|
12
|
-
import { join, resolve } from 'node:path'
|
|
13
|
+
import { dirname, join, resolve } from 'node:path'
|
|
13
14
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
14
15
|
import assert from 'node:assert/strict'
|
|
15
16
|
import ts from 'typescript'
|
|
17
|
+
import { createRequire } from 'node:module'
|
|
16
18
|
|
|
17
19
|
const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
18
20
|
const sourceRoot = process.env.DSH_TUI_SOURCE_ROOT
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
21
|
+
const hostRequire = createRequire(import.meta.url)
|
|
22
|
+
const devPackageJson = hostRequire.resolve('@deepseek-harness-tui/dsh-tui/package.json')
|
|
23
|
+
const hostRoot = sourceRoot === undefined || sourceRoot === ''
|
|
24
|
+
? dirname(devPackageJson)
|
|
25
|
+
: resolve(sourceRoot)
|
|
26
|
+
const sourceMode = sourceRoot !== undefined && sourceRoot !== ''
|
|
27
|
+
const customThemePath = sourceMode
|
|
28
|
+
? join(hostRoot, 'src', 'customTheme.ts')
|
|
29
|
+
: join(hostRoot, 'lib', 'types', 'customTheme.js')
|
|
30
|
+
const themePath = sourceMode
|
|
31
|
+
? join(hostRoot, 'src', 'theme.ts')
|
|
32
|
+
: join(hostRoot, 'lib', 'types', 'theme.d.ts')
|
|
25
33
|
|
|
26
34
|
if (!existsSync(customThemePath) || !existsSync(themePath)) {
|
|
27
|
-
throw new Error(`dsh-TUI sources not found at ${hostRoot}`)
|
|
35
|
+
throw new Error(`dsh-TUI ${sourceMode ? 'sources' : 'devDependency build'} not found at ${hostRoot}`)
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
const hostPackagePath = join(hostRoot, 'package.json')
|
|
38
|
+
const hostPackagePath = sourceMode ? join(hostRoot, 'package.json') : devPackageJson
|
|
31
39
|
if (!existsSync(hostPackagePath)) {
|
|
32
40
|
throw new Error(`dsh-TUI package metadata not found at ${hostPackagePath}`)
|
|
33
41
|
}
|
|
@@ -35,10 +43,10 @@ const hostPackage = JSON.parse(readFileSync(hostPackagePath, 'utf8'))
|
|
|
35
43
|
assert.match(hostPackage.version, /^\d+\.\d+\.\d+(?:[-+].+)?$/u, 'host source must declare a version')
|
|
36
44
|
|
|
37
45
|
const adapter = process.env.DSH_TUI_ADAPTER_DIR
|
|
38
|
-
if (adapter === undefined || adapter === '') {
|
|
46
|
+
if (sourceMode && (adapter === undefined || adapter === '')) {
|
|
39
47
|
throw new Error('DSH_TUI_ADAPTER_DIR must point at dsh-TUI lib/types/dsh-adapter for this host theme validation.')
|
|
40
48
|
}
|
|
41
|
-
const adapterRoot = resolve(adapter)
|
|
49
|
+
const adapterRoot = resolve(adapter || join(hostRoot, 'lib', 'types', 'dsh-adapter'))
|
|
42
50
|
const runtimeThemePath = join(adapterRoot, '..', 'theme.js')
|
|
43
51
|
const runtimeCustomThemePath = join(adapterRoot, '..', 'customTheme.js')
|
|
44
52
|
if (!existsSync(runtimeThemePath) || !existsSync(runtimeCustomThemePath)) {
|
|
@@ -86,7 +94,7 @@ function readThemeKeysFromSource(path) {
|
|
|
86
94
|
return keys
|
|
87
95
|
}
|
|
88
96
|
|
|
89
|
-
const allKeys = readThemeKeysFromSource(themePath)
|
|
97
|
+
const allKeys = sourceMode ? readThemeKeysFromSource(themePath) : Object.keys(getTheme('dark'))
|
|
90
98
|
assert.deepEqual(
|
|
91
99
|
[...Object.keys(getTheme('dark'))].sort(),
|
|
92
100
|
[...allKeys].sort(),
|
|
@@ -40,6 +40,10 @@ for (const required of [
|
|
|
40
40
|
'lib/types/statusLine.d.ts',
|
|
41
41
|
'lib/types/themeAssets.js',
|
|
42
42
|
'lib/types/themeAssets.d.ts',
|
|
43
|
+
'lib/types/runtimeThemes.js',
|
|
44
|
+
'lib/types/runtimeThemes.d.ts',
|
|
45
|
+
'lib/types/toast.js',
|
|
46
|
+
'lib/types/toast.d.ts',
|
|
43
47
|
'themes/pink-night.json',
|
|
44
48
|
'themes/pink-day.json',
|
|
45
49
|
'themes/pink-ansi.json',
|
|
@@ -49,6 +53,7 @@ for (const required of [
|
|
|
49
53
|
'scripts/verify.mjs',
|
|
50
54
|
'scripts/verify-package.mjs',
|
|
51
55
|
'scripts/headless-order-test.mjs',
|
|
56
|
+
'scripts/runtime-themes-headless.mjs',
|
|
52
57
|
'scripts/validate-themes-against-host.mjs',
|
|
53
58
|
'scripts/expected-settings-contract.mjs',
|
|
54
59
|
]) {
|
|
@@ -68,7 +73,15 @@ for (const entry of exportedPaths) {
|
|
|
68
73
|
for (const name of Object.keys(packageJson.peerDependencies)) {
|
|
69
74
|
if (!name.startsWith('@deepseek-ai/')) continue
|
|
70
75
|
assert.equal(packageJson.dependencies?.[name], undefined, `${name} must not be a runtime dependency`)
|
|
71
|
-
|
|
76
|
+
const peerRange = packageJson.peerDependencies[name]
|
|
77
|
+
const devRange = packageJson.devDependencies?.[name]
|
|
78
|
+
// Development baselines may pin one concrete prerelease while the published
|
|
79
|
+
// peer contract remains a union of compatible release lines.
|
|
80
|
+
const devAccepted = peerRange.split('||').some(entry => {
|
|
81
|
+
const candidate = entry.trim()
|
|
82
|
+
return candidate === devRange || candidate.replace(/^\^/u, '') === devRange
|
|
83
|
+
})
|
|
84
|
+
assert.equal(devAccepted, true, `${name} dev pin must be accepted by its peer range`)
|
|
72
85
|
}
|
|
73
86
|
assert.equal(packageJson.dependencies?.['@deepseek-ai/schemastery'], undefined)
|
|
74
87
|
const rootLock = lockfile.packages?.['']
|