dsh-tui-theme 0.6.0 → 0.7.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 +17 -12
- package/cordis.patch.yml +2 -0
- package/docs/decisions/2026-09-12-pink-day-claude-family-lightening.md +49 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +24 -3
- package/lib/types/ornament.d.ts +38 -0
- package/lib/types/ornament.d.ts.map +1 -0
- package/lib/types/ornament.js +88 -0
- package/lib/types/settingsSection.d.ts +9 -4
- package/lib/types/settingsSection.d.ts.map +1 -1
- package/lib/types/settingsSection.js +88 -7
- package/lib/types/shadowCleanup.d.ts +39 -0
- package/lib/types/shadowCleanup.d.ts.map +1 -0
- package/lib/types/shadowCleanup.js +122 -0
- package/lib/types/statusLine.d.ts +32 -3
- package/lib/types/statusLine.d.ts.map +1 -1
- package/lib/types/statusLine.js +204 -33
- package/lib/types/statusView.d.ts +82 -0
- package/lib/types/statusView.d.ts.map +1 -0
- package/lib/types/statusView.js +126 -0
- package/package.json +42 -42
- package/scripts/expected-settings-contract.mjs +21 -1
- package/scripts/headless-order-test.mjs +33 -10
- package/scripts/runtime-themes-headless.mjs +85 -23
- package/scripts/validate-themes-against-host.mjs +25 -5
- package/scripts/verify-package.mjs +6 -0
- package/scripts/verify.mjs +426 -3
- package/themes/pink-day.json +4 -4
- package/themes/pink-night.json +4 -4
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich status view (dsh-TUI >= 0.10.1 seam: `tuiStatus.registerView`).
|
|
3
|
+
*
|
|
4
|
+
* A compact one-row host React component that renders the blossom line with
|
|
5
|
+
* colors taken from the active pink palette — the one thing the scalar
|
|
6
|
+
* `tuiStatus.set` path can never do (the host renders set() text uncolored +
|
|
7
|
+
* terminal dim).
|
|
8
|
+
*
|
|
9
|
+
* Data channel: the component subscribes to a tiny plugin-side external
|
|
10
|
+
* store via the host React's useSyncExternalStore, exactly the "owns its
|
|
11
|
+
* live data via an external store" pattern the host seam documents. The
|
|
12
|
+
* plugin pushes scalar-only snapshots (turn boundaries, the clock tick,
|
|
13
|
+
* settings edits); the component maps one snapshot to themed Text cells and
|
|
14
|
+
* never touches the filesystem, timers, or events itself — it runs on the
|
|
15
|
+
* host's render thread and must stay a pure function of its snapshot.
|
|
16
|
+
*
|
|
17
|
+
* Host kit rules honored here: the component is created with the host React
|
|
18
|
+
* instance handed in via props (single-React rule — the plugin never imports
|
|
19
|
+
* react), and `Box`/`Text` receive only layout/color props (no focus,
|
|
20
|
+
* keyboard, wheel, or ref props; no pointer handlers either — the line is
|
|
21
|
+
* pure display).
|
|
22
|
+
* @module dsh-tui-theme/statusView
|
|
23
|
+
*/
|
|
24
|
+
export const NO_COLORS = Object.freeze({
|
|
25
|
+
glyph: undefined,
|
|
26
|
+
text: undefined,
|
|
27
|
+
separator: undefined,
|
|
28
|
+
});
|
|
29
|
+
function sameSnapshot(a, b) {
|
|
30
|
+
return (a.visible === b.visible &&
|
|
31
|
+
a.glyph === b.glyph &&
|
|
32
|
+
a.clock === b.clock &&
|
|
33
|
+
a.turns === b.turns &&
|
|
34
|
+
a.separator === b.separator &&
|
|
35
|
+
a.colors.glyph === b.colors.glyph &&
|
|
36
|
+
a.colors.text === b.colors.text &&
|
|
37
|
+
a.colors.separator === b.colors.separator);
|
|
38
|
+
}
|
|
39
|
+
export function createStatusStore() {
|
|
40
|
+
const listeners = new Set();
|
|
41
|
+
let current = Object.freeze({
|
|
42
|
+
visible: false,
|
|
43
|
+
glyph: undefined,
|
|
44
|
+
clock: undefined,
|
|
45
|
+
turns: undefined,
|
|
46
|
+
separator: '·',
|
|
47
|
+
colors: NO_COLORS,
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
getSnapshot: () => current,
|
|
51
|
+
subscribe(listener) {
|
|
52
|
+
listeners.add(listener);
|
|
53
|
+
return () => {
|
|
54
|
+
listeners.delete(listener);
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
push(next) {
|
|
58
|
+
if (sameSnapshot(current, next))
|
|
59
|
+
return;
|
|
60
|
+
current = Object.freeze({
|
|
61
|
+
visible: next.visible,
|
|
62
|
+
glyph: next.glyph,
|
|
63
|
+
clock: next.clock,
|
|
64
|
+
turns: next.turns,
|
|
65
|
+
separator: next.separator,
|
|
66
|
+
colors: Object.freeze({
|
|
67
|
+
glyph: next.colors.glyph,
|
|
68
|
+
text: next.colors.text,
|
|
69
|
+
separator: next.colors.separator,
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
for (const listener of [...listeners]) {
|
|
73
|
+
try {
|
|
74
|
+
listener();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// One broken listener must not starve the others.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
clear() {
|
|
82
|
+
listeners.clear();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Build the rich view component bound to one store. The returned function is
|
|
88
|
+
* a host React component: it receives `{ React, ui }` from the host on every
|
|
89
|
+
* render and maps the current snapshot to a one-row themed Box.
|
|
90
|
+
*/
|
|
91
|
+
export function createStatusViewComponent(store) {
|
|
92
|
+
return props => {
|
|
93
|
+
const { React, ui } = props;
|
|
94
|
+
const snapshot = React.useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
95
|
+
if (!snapshot.visible)
|
|
96
|
+
return null;
|
|
97
|
+
const cells = [];
|
|
98
|
+
if (snapshot.glyph !== undefined) {
|
|
99
|
+
cells.push({ text: snapshot.glyph, color: snapshot.colors.glyph });
|
|
100
|
+
}
|
|
101
|
+
if (snapshot.clock !== undefined) {
|
|
102
|
+
cells.push({ text: snapshot.clock, color: snapshot.colors.text });
|
|
103
|
+
}
|
|
104
|
+
if (snapshot.turns !== undefined) {
|
|
105
|
+
cells.push({ text: snapshot.turns, color: snapshot.colors.text });
|
|
106
|
+
}
|
|
107
|
+
if (cells.length === 0)
|
|
108
|
+
return null;
|
|
109
|
+
const children = [];
|
|
110
|
+
for (const [index, cell] of cells.entries()) {
|
|
111
|
+
if (index > 0) {
|
|
112
|
+
children.push(React.createElement(ui.Text, { key: `sep-${index}`, color: snapshot.colors.separator }, ` ${snapshot.separator} `));
|
|
113
|
+
}
|
|
114
|
+
children.push(React.createElement(ui.Text, { key: `cell-${index}`, color: cell.color }, cell.text));
|
|
115
|
+
}
|
|
116
|
+
return React.createElement(ui.Box, { flexDirection: 'row' }, children);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The registration descriptor for the rich view. The caller passes the same
|
|
121
|
+
* contribution key the scalar path uses, so the effect-ledger resource id
|
|
122
|
+
* and the headless order-test pin remain stable across both paths.
|
|
123
|
+
*/
|
|
124
|
+
export function statusViewDescriptor(key, component) {
|
|
125
|
+
return { key, maxRows: 1, component };
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tui-theme",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -58,57 +58,57 @@
|
|
|
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 || ^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",
|
|
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",
|
|
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": "0.1.2-alpha.
|
|
68
|
-
"@deepseek-ai/dsh-settings": "0.1.2-alpha.
|
|
67
|
+
"@deepseek-ai/dsh-session": "0.1.2-alpha.3",
|
|
68
|
+
"@deepseek-ai/dsh-settings": "0.1.2-alpha.3",
|
|
69
69
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
70
|
-
"@deepseek-harness-tui/dsh-tui": "0.10.
|
|
70
|
+
"@deepseek-harness-tui/dsh-tui": "0.10.1",
|
|
71
71
|
"@types/node": "^22.0.0",
|
|
72
72
|
"tsx": "^4.23.12",
|
|
73
73
|
"typescript": "^6.0.3",
|
|
74
|
-
"@deepseek-ai/dsh-llm": "0.1.2-alpha.
|
|
75
|
-
"@deepseek-ai/dsh-agent": "0.1.2-alpha.
|
|
76
|
-
"@deepseek-ai/dsh-user-questions": "0.1.2-alpha.
|
|
77
|
-
"@deepseek-ai/dsh-user-approval": "0.1.2-alpha.
|
|
78
|
-
"@deepseek-ai/dsh-commands": "0.1.2-alpha.
|
|
79
|
-
"@deepseek-ai/dsh-atomic-write": "0.1.2-alpha.
|
|
80
|
-
"@deepseek-ai/dsh-tool-ask-user": "0.1.2-alpha.
|
|
81
|
-
"@deepseek-ai/dsh-skill": "0.1.2-alpha.
|
|
82
|
-
"@deepseek-ai/dsh-agent-instructions": "0.1.2-alpha.
|
|
83
|
-
"@deepseek-ai/dsh-tools": "0.1.2-alpha.
|
|
84
|
-
"@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.
|
|
85
|
-
"@deepseek-ai/dsh-invariants": "0.1.2-alpha.
|
|
74
|
+
"@deepseek-ai/dsh-llm": "0.1.2-alpha.3",
|
|
75
|
+
"@deepseek-ai/dsh-agent": "0.1.2-alpha.3",
|
|
76
|
+
"@deepseek-ai/dsh-user-questions": "0.1.2-alpha.3",
|
|
77
|
+
"@deepseek-ai/dsh-user-approval": "0.1.2-alpha.3",
|
|
78
|
+
"@deepseek-ai/dsh-commands": "0.1.2-alpha.3",
|
|
79
|
+
"@deepseek-ai/dsh-atomic-write": "0.1.2-alpha.3",
|
|
80
|
+
"@deepseek-ai/dsh-tool-ask-user": "0.1.2-alpha.3",
|
|
81
|
+
"@deepseek-ai/dsh-skill": "0.1.2-alpha.3",
|
|
82
|
+
"@deepseek-ai/dsh-agent-instructions": "0.1.2-alpha.3",
|
|
83
|
+
"@deepseek-ai/dsh-tools": "0.1.2-alpha.3",
|
|
84
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.3",
|
|
85
|
+
"@deepseek-ai/dsh-invariants": "0.1.2-alpha.3"
|
|
86
86
|
},
|
|
87
87
|
"overrides": {
|
|
88
|
-
"@deepseek-ai/dsh-session": "0.1.2-alpha.
|
|
89
|
-
"@deepseek-ai/dsh-settings": "0.1.2-alpha.
|
|
90
|
-
"@deepseek-ai/dsh-llm": "0.1.2-alpha.
|
|
91
|
-
"@deepseek-ai/dsh-agent": "0.1.2-alpha.
|
|
92
|
-
"@deepseek-ai/dsh-user-questions": "0.1.2-alpha.
|
|
93
|
-
"@deepseek-ai/dsh-user-approval": "0.1.2-alpha.
|
|
94
|
-
"@deepseek-ai/dsh-commands": "0.1.2-alpha.
|
|
95
|
-
"@deepseek-ai/dsh-atomic-write": "0.1.2-alpha.
|
|
96
|
-
"@deepseek-ai/dsh-tool-ask-user": "0.1.2-alpha.
|
|
97
|
-
"@deepseek-ai/dsh-skill": "0.1.2-alpha.
|
|
98
|
-
"@deepseek-ai/dsh-agent-instructions": "0.1.2-alpha.
|
|
99
|
-
"@deepseek-ai/dsh-tools": "0.1.2-alpha.
|
|
100
|
-
"@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.
|
|
101
|
-
"@deepseek-ai/dsh-invariants": "0.1.2-alpha.
|
|
102
|
-
"@deepseek-ai/dsh-scope": "0.1.2-alpha.
|
|
103
|
-
"@deepseek-ai/dsh-attachment": "0.1.2-alpha.
|
|
104
|
-
"@deepseek-ai/dsh-brand": "0.1.2-alpha.
|
|
105
|
-
"@deepseek-ai/dsh-typert-protocol": "0.1.2-alpha.
|
|
106
|
-
"@deepseek-ai/dsh-timeout": "0.1.2-alpha.
|
|
107
|
-
"@deepseek-ai/dsh-session-projection": "0.1.2-alpha.
|
|
108
|
-
"@deepseek-ai/dsh-code-runtime": "0.1.2-alpha.
|
|
109
|
-
"@deepseek-ai/dsh-fs": "0.1.2-alpha.
|
|
110
|
-
"@deepseek-ai/dsh-home-paths": "0.1.2-alpha.
|
|
111
|
-
"@deepseek-ai/dsh-sandbox": "0.1.2-alpha.
|
|
88
|
+
"@deepseek-ai/dsh-session": "0.1.2-alpha.3",
|
|
89
|
+
"@deepseek-ai/dsh-settings": "0.1.2-alpha.3",
|
|
90
|
+
"@deepseek-ai/dsh-llm": "0.1.2-alpha.3",
|
|
91
|
+
"@deepseek-ai/dsh-agent": "0.1.2-alpha.3",
|
|
92
|
+
"@deepseek-ai/dsh-user-questions": "0.1.2-alpha.3",
|
|
93
|
+
"@deepseek-ai/dsh-user-approval": "0.1.2-alpha.3",
|
|
94
|
+
"@deepseek-ai/dsh-commands": "0.1.2-alpha.3",
|
|
95
|
+
"@deepseek-ai/dsh-atomic-write": "0.1.2-alpha.3",
|
|
96
|
+
"@deepseek-ai/dsh-tool-ask-user": "0.1.2-alpha.3",
|
|
97
|
+
"@deepseek-ai/dsh-skill": "0.1.2-alpha.3",
|
|
98
|
+
"@deepseek-ai/dsh-agent-instructions": "0.1.2-alpha.3",
|
|
99
|
+
"@deepseek-ai/dsh-tools": "0.1.2-alpha.3",
|
|
100
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.3",
|
|
101
|
+
"@deepseek-ai/dsh-invariants": "0.1.2-alpha.3",
|
|
102
|
+
"@deepseek-ai/dsh-scope": "0.1.2-alpha.3",
|
|
103
|
+
"@deepseek-ai/dsh-attachment": "0.1.2-alpha.3",
|
|
104
|
+
"@deepseek-ai/dsh-brand": "0.1.2-alpha.3",
|
|
105
|
+
"@deepseek-ai/dsh-typert-protocol": "0.1.2-alpha.3",
|
|
106
|
+
"@deepseek-ai/dsh-timeout": "0.1.2-alpha.3",
|
|
107
|
+
"@deepseek-ai/dsh-session-projection": "0.1.2-alpha.3",
|
|
108
|
+
"@deepseek-ai/dsh-code-runtime": "0.1.2-alpha.3",
|
|
109
|
+
"@deepseek-ai/dsh-fs": "0.1.2-alpha.3",
|
|
110
|
+
"@deepseek-ai/dsh-home-paths": "0.1.2-alpha.3",
|
|
111
|
+
"@deepseek-ai/dsh-sandbox": "0.1.2-alpha.3"
|
|
112
112
|
},
|
|
113
113
|
"allowScripts": {
|
|
114
114
|
"esbuild@0.28.2": true
|
|
@@ -25,13 +25,18 @@ export const SETTINGS_FIELDS = [
|
|
|
25
25
|
[['showClock'], 'boolean'],
|
|
26
26
|
[['showTurns'], 'boolean'],
|
|
27
27
|
[['statusScope'], 'select'],
|
|
28
|
+
[['statusGlyph'], 'text'],
|
|
29
|
+
[['statusSeparator'], 'text'],
|
|
28
30
|
]
|
|
29
31
|
|
|
30
32
|
/** The values the statusScope select must expose, looked up by field path. */
|
|
31
33
|
export const STATUS_SCOPE_FIELD_PATH = ['statusScope']
|
|
32
34
|
export const STATUS_SCOPE_OPTIONS = ['pink-only', 'all-themes']
|
|
33
35
|
|
|
34
|
-
/**
|
|
36
|
+
/** Navigation group ids; every field must name one of them. */
|
|
37
|
+
export const SETTINGS_GROUPS = ['follow', 'status-line']
|
|
38
|
+
|
|
39
|
+
/** Assert a section object (ns + groups + fields) matches the contract. */
|
|
35
40
|
export function assertSettingsContract(assert, section) {
|
|
36
41
|
assert.equal(section.ns, SETTINGS_NAMESPACE, 'settings namespace must match')
|
|
37
42
|
const actual = section.fields.map(field => [field.path, field.kind])
|
|
@@ -50,4 +55,19 @@ export function assertSettingsContract(assert, section) {
|
|
|
50
55
|
STATUS_SCOPE_OPTIONS,
|
|
51
56
|
'status scope must expose both supported select values',
|
|
52
57
|
)
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
(section.groups ?? []).map(group => group.id).sort(),
|
|
60
|
+
[...SETTINGS_GROUPS].sort(),
|
|
61
|
+
'settings groups must remain compatible with the declared navigation',
|
|
62
|
+
)
|
|
63
|
+
for (const field of section.fields) {
|
|
64
|
+
assert.equal(
|
|
65
|
+
typeof field.group, 'string',
|
|
66
|
+
`field ${JSON.stringify(field.path)} must declare its navigation group`,
|
|
67
|
+
)
|
|
68
|
+
assert.equal(
|
|
69
|
+
SETTINGS_GROUPS.includes(field.group), true,
|
|
70
|
+
`field ${JSON.stringify(field.path)} names an unknown group`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
53
73
|
}
|
|
@@ -102,30 +102,35 @@ const collectBinds = () => {
|
|
|
102
102
|
.filter(entry => entry.resource?.id === STATUS_CONTRIBUTION_KEY)
|
|
103
103
|
: []
|
|
104
104
|
}
|
|
105
|
+
// The plugin renders its contribution through the rich view path on hosts
|
|
106
|
+
// with tuiStatus.registerView (0.10.1+) and through the scalar set() path on
|
|
107
|
+
// older ones — the key is the same, only the store half differs.
|
|
105
108
|
const readState = () => {
|
|
106
109
|
const runtime = app.get('tuiStatus')
|
|
107
110
|
const settingsRuntime = app.get('tuiSettingsSections')
|
|
108
111
|
const settingsHost = settingsSectionsModule.getHostSettingsSections(settingsRuntime)
|
|
112
|
+
const store = statusModule.getHostStatusStore(runtime)
|
|
109
113
|
return {
|
|
110
114
|
pinkBinds: collectBinds(),
|
|
111
|
-
snapshot:
|
|
115
|
+
snapshot: store?.getSnapshot(),
|
|
116
|
+
viewSnapshot: typeof store?.getViewSnapshot === 'function' ? store.getViewSnapshot() : [],
|
|
112
117
|
settingsSection: settingsHost?.list().find(section => section.ns === SETTINGS_NAMESPACE),
|
|
113
118
|
}
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
let state = readState()
|
|
117
122
|
const deadline = Date.now() + READY_TIMEOUT_MS
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
) {
|
|
123
|
+
const contributionVisible = s =>
|
|
124
|
+
s.pinkBinds.length > 0 &&
|
|
125
|
+
(s.snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY) === true ||
|
|
126
|
+
s.viewSnapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY) === true) &&
|
|
127
|
+
s.settingsSection !== undefined
|
|
128
|
+
while (!contributionVisible(state) && Date.now() < deadline) {
|
|
124
129
|
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
|
125
130
|
state = readState()
|
|
126
131
|
}
|
|
127
132
|
|
|
128
|
-
const { pinkBinds, snapshot, settingsSection } = state
|
|
133
|
+
const { pinkBinds, snapshot, viewSnapshot, settingsSection } = state
|
|
129
134
|
assert.equal(
|
|
130
135
|
STATUS_CONTRIBUTION_KEY,
|
|
131
136
|
SETTINGS_NAMESPACE,
|
|
@@ -133,7 +138,8 @@ assert.equal(
|
|
|
133
138
|
)
|
|
134
139
|
assert.ok(pinkBinds.length > 0, `plugin must bind through the late status service within ${READY_TIMEOUT_MS}ms`)
|
|
135
140
|
assert.ok(
|
|
136
|
-
snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY)
|
|
141
|
+
snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY) ||
|
|
142
|
+
viewSnapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY),
|
|
137
143
|
`status store must contain the plugin contribution within ${READY_TIMEOUT_MS}ms`,
|
|
138
144
|
)
|
|
139
145
|
assert.ok(
|
|
@@ -141,4 +147,21 @@ assert.ok(
|
|
|
141
147
|
`plugin must register its /settings section through the late settings service within ${READY_TIMEOUT_MS}ms`,
|
|
142
148
|
)
|
|
143
149
|
assertSettingsContract(assert, settingsSection)
|
|
144
|
-
|
|
150
|
+
if (typeof statusModule.getHostStatusStore(app.get('tuiStatus'))?.getViewSnapshot === 'function') {
|
|
151
|
+
const view = viewSnapshot.find(entry => entry.key === STATUS_CONTRIBUTION_KEY)
|
|
152
|
+
assert.ok(view, 'a registerView-capable host must carry the contribution as a rich view')
|
|
153
|
+
assert.equal(view.maxRows, 1, 'the rich status view requests exactly one row')
|
|
154
|
+
assert.equal(typeof view.component, 'function', 'the rich status view carries a component')
|
|
155
|
+
assert.equal(
|
|
156
|
+
snapshot.some(entry => entry.key === STATUS_CONTRIBUTION_KEY),
|
|
157
|
+
false,
|
|
158
|
+
'the two render paths are mutually exclusive: the rich path contributes no scalar text',
|
|
159
|
+
)
|
|
160
|
+
console.log('OK headless order: rich view registered (maxRows 1), ledger bind, settings section')
|
|
161
|
+
} else {
|
|
162
|
+
assert.ok(
|
|
163
|
+
snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY),
|
|
164
|
+
'a set()-only host must carry the contribution as scalar text',
|
|
165
|
+
)
|
|
166
|
+
console.log(`OK headless order: ${pinkBinds.length} ledger bind(s), status contribution, settings section`)
|
|
167
|
+
}
|
|
@@ -37,36 +37,69 @@ const pluginHost = await import(pathToFileURL(join(adapter, 'plugin-host.js')).h
|
|
|
37
37
|
const extensions = await import(pathToFileURL(join(adapter, 'extensions.js')).href)
|
|
38
38
|
const themesModule = await import(pathToFileURL(join(adapter, 'themes.js')).href)
|
|
39
39
|
const themeModule = await import(pathToFileURL(join(adapter, '..', 'theme.js')).href)
|
|
40
|
-
|
|
40
|
+
// dsh-TUI >= 0.10.1 dropped test-utils.js from the npm package; fall back to a
|
|
41
|
+
// manual plugin mount (same shape as headless-order-test.mjs). The admitted
|
|
42
|
+
// path stays first so release-ledger bookkeeping stays asserted where the
|
|
43
|
+
// admission helpers exist.
|
|
44
|
+
const testUtils = existsSync(join(adapter, '..', 'test-utils.js'))
|
|
45
|
+
? await import(pathToFileURL(join(adapter, '..', 'test-utils.js')).href)
|
|
46
|
+
: undefined
|
|
47
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
48
|
+
const mountPlugin = async app => {
|
|
49
|
+
if (testUtils === undefined) {
|
|
50
|
+
return { context: app, fiber: app.fiber, manual: true }
|
|
51
|
+
}
|
|
52
|
+
const manifest = testUtils.testManifest({ id: SETTINGS_NAMESPACE })
|
|
53
|
+
const admitted = await testUtils.mountAdmitted(app, SETTINGS_NAMESPACE, manifest)
|
|
54
|
+
return { context: admitted.context, fiber: admitted.fiber, manual: false }
|
|
55
|
+
}
|
|
41
56
|
const pink = await import(pathToFileURL(join(pluginRoot, 'lib', 'types', 'index.js')).href)
|
|
42
57
|
const { SETTINGS_NAMESPACE } = await import(pathToFileURL(join(pluginRoot, 'scripts', 'expected-settings-contract.mjs')).href)
|
|
43
58
|
|
|
44
59
|
const app = new Context()
|
|
45
60
|
await app.plugin(pluginHost.default ?? pluginHost)
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
await
|
|
61
|
+
const mount = await mountPlugin(app)
|
|
62
|
+
if (mount.manual) console.log('* mountAdmitted unavailable on this host; using manual plugin mount')
|
|
63
|
+
await mount.context.plugin(pink)
|
|
49
64
|
await app.plugin(extensions.default ?? extensions)
|
|
50
65
|
|
|
51
66
|
const host = themesModule.getHostThemes(app.get('tuiThemes'))
|
|
52
67
|
assert.ok(host, 'runtime theme host must be mounted')
|
|
53
68
|
const deadline = Date.now() + 5_000
|
|
54
69
|
while (host.getSnapshot().length !== 3 && Date.now() < deadline) {
|
|
55
|
-
await
|
|
70
|
+
await sleep(25)
|
|
56
71
|
}
|
|
57
72
|
const snapshot = host.getSnapshot()
|
|
58
73
|
assert.deepEqual(snapshot.map(entry => entry.name).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
|
|
59
74
|
|
|
75
|
+
// dsh-TUI >= 0.10.1 semantic-rename sentinel: the host normalizes legacy keys
|
|
76
|
+
// at admission (aliases map to canonical names, retired keys are dropped),
|
|
77
|
+
// while pre-0.10.1 hosts snapshot the descriptor colors verbatim.
|
|
78
|
+
const semanticKeys = 'accent' in themeModule.getTheme('dark')
|
|
60
79
|
for (const entry of snapshot) {
|
|
61
80
|
const expected = JSON.parse(readFileSync(join(pluginRoot, 'themes', `${entry.name}.json`), 'utf8'))
|
|
62
81
|
assert.equal(entry.displayName, expected.displayName)
|
|
63
82
|
assert.equal(entry.base, expected.base)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
if (semanticKeys) {
|
|
84
|
+
assert.equal(entry.colors.accent, expected.colors.claude)
|
|
85
|
+
assert.equal(entry.colors.accentShimmer, expected.colors.claudeShimmer)
|
|
86
|
+
assert.equal(entry.colors.activity, expected.colors.claudeBlue_FOR_SYSTEM_SPINNER)
|
|
87
|
+
assert.equal(entry.colors.activityShimmer, expected.colors.claudeBlueShimmer_FOR_SYSTEM_SPINNER)
|
|
88
|
+
assert.equal(entry.colors.mascotBody, expected.colors.clawd_body)
|
|
89
|
+
assert.equal(entry.colors.inputBackground, expected.colors.clawd_background)
|
|
90
|
+
assert.equal(entry.colors.userPromptLabel, expected.colors.briefLabelYou)
|
|
91
|
+
assert.equal(entry.colors.text, expected.colors.text)
|
|
92
|
+
assert.equal('rainbow_red' in entry.colors, false, 'retired keys are dropped at admission')
|
|
93
|
+
assert.equal(host.resolve(entry.name).accent, expected.colors.claude)
|
|
94
|
+
assert.equal(themeModule.getTheme(entry.name).accent, expected.colors.claude)
|
|
95
|
+
} else {
|
|
96
|
+
assert.deepEqual(entry.colors, expected.colors)
|
|
97
|
+
assert.deepEqual(host.resolve(entry.name), { ...themeModule.getTheme(entry.base), ...expected.colors })
|
|
98
|
+
assert.equal(themeModule.getTheme(entry.name).claude, expected.colors.claude)
|
|
99
|
+
}
|
|
67
100
|
}
|
|
68
101
|
assert.equal(existsSync(staticThemes), false, 'runtime registration must not create static theme files')
|
|
69
|
-
await
|
|
102
|
+
await sleep(1_700)
|
|
70
103
|
assert.equal(existsSync(staticThemes), false, 'runtime confirmation must leave no static fallback files')
|
|
71
104
|
|
|
72
105
|
const ledgerPath = join(dataDir, 'effect-ledger.jsonl')
|
|
@@ -74,11 +107,13 @@ const readLedger = () => (existsSync(ledgerPath) ? readFileSync(ledgerPath, 'utf
|
|
|
74
107
|
const themeCreates = readLedger().filter(entry => entry.resource?.kind === 'theme' && entry.operation === 'create')
|
|
75
108
|
assert.deepEqual(themeCreates.map(entry => entry.resource.id).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
|
|
76
109
|
|
|
77
|
-
await
|
|
78
|
-
await
|
|
110
|
+
await mount.fiber.dispose()
|
|
111
|
+
await sleep(50)
|
|
79
112
|
assert.deepEqual(host.getSnapshot(), [], 'disposing the admitted plugin must release runtime themes')
|
|
80
|
-
|
|
81
|
-
|
|
113
|
+
if (!mount.manual) {
|
|
114
|
+
const themeReleases = readLedger().filter(entry => entry.resource?.kind === 'theme' && entry.operation === 'release')
|
|
115
|
+
assert.deepEqual(themeReleases.map(entry => entry.resource.id).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
|
|
116
|
+
}
|
|
82
117
|
assert.deepEqual(themeModule.getTheme('pink-night'), themeModule.getTheme('dark'), 'disposed runtime theme no longer resolves')
|
|
83
118
|
console.log('OK runtime themes: 3 registered, no static files, ledger create/release, disposal clean')
|
|
84
119
|
|
|
@@ -106,23 +141,50 @@ for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
|
|
|
106
141
|
const deliveries = []
|
|
107
142
|
const app2 = new Context()
|
|
108
143
|
await app2.plugin(pluginHost.default ?? pluginHost)
|
|
109
|
-
const
|
|
110
|
-
await
|
|
144
|
+
const mount2 = await mountPlugin(app2)
|
|
145
|
+
await mount2.context.plugin(pink)
|
|
111
146
|
await app2.plugin(extensions.default ?? extensions)
|
|
112
147
|
toastModule.getHostToastStore(app2.get('tuiToast'))?.setSink(delivery => deliveries.push(delivery))
|
|
113
148
|
|
|
114
149
|
const toastDeadline = Date.now() + 8_000
|
|
115
150
|
while (deliveries.length === 0 && Date.now() < toastDeadline) {
|
|
116
|
-
await
|
|
151
|
+
await sleep(25)
|
|
117
152
|
}
|
|
118
153
|
assert.ok(deliveries.length >= 1, 'the shadow-hint toast must reach the host sink (retry included)')
|
|
119
154
|
assert.equal(deliveries[0].color, undefined, 'the shadow hint is neutral')
|
|
120
155
|
for (const file of ['pink-night.json', 'pink-day.json', 'pink-ansi.json']) {
|
|
121
156
|
assert.ok(deliveries[0].text.includes(file), `hint must name ${file}`)
|
|
122
157
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
158
|
+
|
|
159
|
+
// The same shadow situation additionally offers the one-shot guided cleanup
|
|
160
|
+
// dialog through the real tuiDialogs seam (host-managed confirm panel).
|
|
161
|
+
// Headless has no chat screen: the request parks in the dialog store, which
|
|
162
|
+
// is exactly the early-boot timing the plugin relies on — the dialog becomes
|
|
163
|
+
// visible as soon as the chat screen drains the store.
|
|
164
|
+
const dialogsModule = await import(pathToFileURL(join(adapter, 'dialogs.js')).href)
|
|
165
|
+
const dialogStore = dialogsModule.getHostDialogStore(app2.get('tuiDialogs'))
|
|
166
|
+
assert.ok(dialogStore, 'the real host must expose the dialog store')
|
|
167
|
+
const dialogDeadline = Date.now() + 5_000
|
|
168
|
+
while (dialogStore.getSnapshot() === null && Date.now() < dialogDeadline) {
|
|
169
|
+
await sleep(25)
|
|
170
|
+
}
|
|
171
|
+
const dialogSnapshot = dialogStore.getSnapshot()
|
|
172
|
+
assert.ok(dialogSnapshot, 'the shadow situation must offer the cleanup dialog')
|
|
173
|
+
assert.equal(dialogSnapshot.kind, 'confirm')
|
|
174
|
+
assert.ok(dialogSnapshot.title.includes('旧主题文件'), 'the dialog title names the cleanup')
|
|
175
|
+
for (const file of ['pink-night.json', 'pink-day.json', 'pink-ansi.json']) {
|
|
176
|
+
assert.ok((dialogSnapshot.message ?? '').includes(file), `the dialog message must name ${file}`)
|
|
177
|
+
}
|
|
178
|
+
assert.equal(existsSync(join(dataDir2, 'themes', 'pink-night.json')), true, 'an unanswered dialog deletes nothing')
|
|
179
|
+
await mount2.fiber.dispose()
|
|
180
|
+
await sleep(50)
|
|
181
|
+
assert.equal(dialogStore.getSnapshot(), null, 'disposing the activation settles the parked dialog')
|
|
182
|
+
assert.equal(
|
|
183
|
+
existsSync(join(dataDir2, 'themes', 'pink-night.json')),
|
|
184
|
+
true,
|
|
185
|
+
'a cancelled dialog must not delete the shadow files',
|
|
186
|
+
)
|
|
187
|
+
console.log('OK toast: shadow hint delivered through the real tuiToast seam; cleanup dialog offered and settled')
|
|
126
188
|
|
|
127
189
|
// ── Phase 3: apply-time toasts reach the sink through the seam-late retry ───
|
|
128
190
|
// On a real 0.10 host the tuiToast service arrives with the extensions row,
|
|
@@ -157,15 +219,15 @@ const fakeSettings = {
|
|
|
157
219
|
const deliveries3 = []
|
|
158
220
|
const app3 = new Context()
|
|
159
221
|
await app3.plugin(pluginHost.default ?? pluginHost)
|
|
160
|
-
const
|
|
222
|
+
const mount3 = await mountPlugin(app3)
|
|
161
223
|
app3.provide('settings', fakeSettings)
|
|
162
|
-
await
|
|
224
|
+
await mount3.context.plugin(pink)
|
|
163
225
|
await app3.plugin(extensions.default ?? extensions)
|
|
164
226
|
toastModule.getHostToastStore(app3.get('tuiToast'))?.setSink(delivery => deliveries3.push(delivery))
|
|
165
227
|
|
|
166
228
|
const applyToastDeadline = Date.now() + 8_000
|
|
167
229
|
while (deliveries3.length < 2 && Date.now() < applyToastDeadline) {
|
|
168
|
-
await
|
|
230
|
+
await sleep(25)
|
|
169
231
|
}
|
|
170
232
|
const healToast = deliveries3.find(delivery => delivery.text.includes('已修复'))
|
|
171
233
|
assert.ok(healToast, 'the self-heal warning fired during apply must reach the host sink')
|
|
@@ -176,5 +238,5 @@ assert.ok(followToast, 'the boot-follow result fired at settings time must reach
|
|
|
176
238
|
assert.equal(followToast.color, 'success', 'the boot-follow toast is a success')
|
|
177
239
|
assert.ok(followToast.text.includes('pink-day') && followToast.text.includes('reload'), 'the follow toast names the new theme and the reload hint')
|
|
178
240
|
assert.equal(JSON.parse(readFileSync(join(dataDir3, 'theme.json'), 'utf8')).theme, 'pink-day', 'the follow pref write still happened')
|
|
179
|
-
await
|
|
241
|
+
await mount3.fiber.dispose()
|
|
180
242
|
console.log('OK toast phase 3: apply-time self-heal and boot-follow toasts delivered on the real host')
|
|
@@ -100,7 +100,10 @@ assert.deepEqual(
|
|
|
100
100
|
[...allKeys].sort(),
|
|
101
101
|
'compiled theme keys must match the checked-out host source',
|
|
102
102
|
)
|
|
103
|
-
|
|
103
|
+
// The absolute key floor from the 96-key era does not survive the 0.10.1
|
|
104
|
+
// semantic refactor (73 canonical keys); per-key coverage below is the real
|
|
105
|
+
// guarantee, so the count is reported instead of pinned to a hardcoded floor.
|
|
106
|
+
console.log(`* host Theme key count: ${allKeys.length} (coverage asserted per key)`)
|
|
104
107
|
|
|
105
108
|
const settingsKeys = [
|
|
106
109
|
'promptBorder',
|
|
@@ -185,7 +188,19 @@ const cases = [
|
|
|
185
188
|
['pink-night', 'success', '#55303E', 3.0],
|
|
186
189
|
['pink-night', 'inactive', '#55303E', 3.0],
|
|
187
190
|
['pink-day', 'text', '#F6F3ED', 4.5],
|
|
188
|
-
|
|
191
|
+
// claude drives the 5-row header display font and bold+underlined markdown
|
|
192
|
+
// headings; the sakura-day identity deliberately goes one notch lighter
|
|
193
|
+
// (#DE6E96), so the large-text floor here is 2.5 instead of 3.0. Body-text
|
|
194
|
+
// readability stays guarded by the 4.5 `text` case above.
|
|
195
|
+
['pink-day', 'claude', '#F6F3ED', 2.5],
|
|
196
|
+
// The DEEPSEEK pixel word's gradient end (claudeBlue_FOR_SYSTEM_SPINNER,
|
|
197
|
+
// 0.10.1 `activity`) was lightened one notch with the same sakura-day
|
|
198
|
+
// family move (#DE6E96 -> #E879A0), so the gradient reads as a fade. The
|
|
199
|
+
// measured 2.47 is accepted for this decorative gradient end and guarded
|
|
200
|
+
// here at 2.4 — the floor exists to catch further lightening, not to
|
|
201
|
+
// re-litigate the accepted 0.03 gap below the 2.5 large-text floor.
|
|
202
|
+
// Decision record: docs/decisions/2026-09-12-pink-day-claude-family-lightening.md
|
|
203
|
+
['pink-day', 'claudeBlue_FOR_SYSTEM_SPINNER', '#F6F3ED', 2.4],
|
|
189
204
|
['pink-day', 'inactive', '#F6F3ED', 2.5],
|
|
190
205
|
['pink-day', 'success', '#F6F3ED', 3.0],
|
|
191
206
|
['pink-day', 'success', '#F3D7E0', 2.5],
|
|
@@ -196,12 +211,17 @@ const cases = [
|
|
|
196
211
|
// ratio is assertable here — the host renders whatever the user's terminal
|
|
197
212
|
// palette defines. Its settings keys are still covered by the per-theme
|
|
198
213
|
// key-coverage assertions above.
|
|
214
|
+
// Hosts >= 0.10.1 resolve built themes to canonical keys, so era keys read
|
|
215
|
+
// their canonical names there; pre-refactor hosts keep the era keys.
|
|
216
|
+
const semanticKeys = 'accent' in built['pink-night']
|
|
217
|
+
const semanticAliases = { claude: 'accent', claudeBlue_FOR_SYSTEM_SPINNER: 'activity' }
|
|
199
218
|
for (const [theme, key, background, minimum] of cases) {
|
|
200
|
-
const
|
|
219
|
+
const probeKey = (semanticKeys && semanticAliases[key]) || key
|
|
220
|
+
const foregroundRgb = parseColor(built[theme][probeKey])
|
|
201
221
|
const backgroundRgb = parseColor(background)
|
|
202
|
-
assert.notEqual(foregroundRgb, undefined, `${theme}.${
|
|
222
|
+
assert.notEqual(foregroundRgb, undefined, `${theme}.${probeKey} must be parseable`)
|
|
203
223
|
const ratio = contrast(foregroundRgb, backgroundRgb)
|
|
204
|
-
assert.ok(ratio >= minimum, `${theme}.${
|
|
224
|
+
assert.ok(ratio >= minimum, `${theme}.${probeKey} contrast ${ratio.toFixed(2)} >= ${minimum}`)
|
|
205
225
|
}
|
|
206
226
|
|
|
207
227
|
console.log(`OK host theme validation: ${Object.keys(built).length} themes, ${allKeys.length - 1} keys each, settings colors covered`)
|
|
@@ -44,6 +44,12 @@ for (const required of [
|
|
|
44
44
|
'lib/types/runtimeThemes.d.ts',
|
|
45
45
|
'lib/types/toast.js',
|
|
46
46
|
'lib/types/toast.d.ts',
|
|
47
|
+
'lib/types/statusView.js',
|
|
48
|
+
'lib/types/statusView.d.ts',
|
|
49
|
+
'lib/types/ornament.js',
|
|
50
|
+
'lib/types/ornament.d.ts',
|
|
51
|
+
'lib/types/shadowCleanup.js',
|
|
52
|
+
'lib/types/shadowCleanup.d.ts',
|
|
47
53
|
'themes/pink-night.json',
|
|
48
54
|
'themes/pink-day.json',
|
|
49
55
|
'themes/pink-ansi.json',
|