dsh-tui-theme 0.6.1 → 0.7.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.
@@ -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
+ }
@@ -19,6 +19,10 @@ export interface ThemeInstallResult {
19
19
  readonly repaired: readonly string[];
20
20
  /** Files that could not be installed (per-file failures). */
21
21
  readonly failed: readonly string[];
22
+ /** Set only when the bundled directory itself was unreadable, so nothing
23
+ * was attempted and no per-file entry exists. Carries the directory and
24
+ * the underlying error for the caller's log. */
25
+ readonly sourceError?: string;
22
26
  }
23
27
  export interface BundledTheme {
24
28
  readonly file: string;
@@ -1 +1 @@
1
- {"version":3,"file":"themeAssets.d.ts","sourceRoot":"","sources":["../../src/themeAssets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAOH,MAAM,WAAW,kBAAkB;IACjC,qDAAqD;IACrD,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;IACrC,sEAAsE;IACtE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,6DAA6D;IAC7D,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CACnC;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,WAAW,CAAA;IAC7C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACxC;AAID,wBAAgB,OAAO,IAAI,MAAM,CAEhC;AAED,2EAA2E;AAC3E,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED,2DAA2D;AAC3D,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,MAA2B,GAAG,YAAY,EAAE,CA0CxF;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,kBAAkB,CAmCpB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,SAAS,MAAM,EAAE,EACxB,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,MAAM,EAAE,CAuBV;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,MAAM,EAAE,CAkBV"}
1
+ {"version":3,"file":"themeAssets.d.ts","sourceRoot":"","sources":["../../src/themeAssets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAOH,MAAM,WAAW,kBAAkB;IACjC,qDAAqD;IACrD,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;IACrC,sEAAsE;IACtE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,6DAA6D;IAC7D,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;IAClC;;qDAEiD;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAC9B;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,WAAW,CAAA;IAC7C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACxC;AAOD,wBAAgB,OAAO,IAAI,MAAM,CAEhC;AAED,2EAA2E;AAC3E,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED,2DAA2D;AAC3D,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,SAAS,GAAE,MAA2B,GAAG,YAAY,EAAE,CA0CxF;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,kBAAkB,CAqCpB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,SAAS,MAAM,EAAE,EACxB,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,MAAM,EAAE,CAuBV;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,GAAE,MAA0B,EACrC,SAAS,GAAE,MAA2B,GACrC,MAAM,EAAE,CAkBV"}
@@ -15,7 +15,10 @@ import { homedir } from 'node:os';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  // Same resolution order as the host's utils/paths.ts homeDir(): os.homedir()
18
- // first, USERPROFILE/HOME spellings as stripped-down fallbacks.
18
+ // first, USERPROFILE/HOME spellings as stripped-down fallbacks. When all three
19
+ // come up empty the returned '' makes every derived path resolve against the
20
+ // process cwd — the host's own semantics, kept deliberately identical rather
21
+ // than second-guessed here.
19
22
  export function homeDir() {
20
23
  return homedir() || process.env.USERPROFILE || process.env.HOME || '';
21
24
  }
@@ -87,8 +90,10 @@ export function installBundledThemes(targetDir = themesTargetDir(), sourceDir =
87
90
  try {
88
91
  files = readdirSync(sourceDir).filter(entry => entry.toLowerCase().endsWith('.json'));
89
92
  }
90
- catch {
91
- return { installed, skipped, repaired, failed: [sourceDir] };
93
+ catch (error) {
94
+ // No file was attempted, so `failed` stays empty; the directory-level
95
+ // reason travels separately so the caller can log a precise line.
96
+ return { installed, skipped, repaired, failed, sourceError: `could not read bundled themes from ${sourceDir}: ${String(error)}` };
92
97
  }
93
98
  for (const file of files) {
94
99
  const target = join(targetDir, file);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tui-theme",
3
- "version": "0.6.1",
3
+ "version": "0.7.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",
@@ -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.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.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.2",
68
- "@deepseek-ai/dsh-settings": "0.1.2-alpha.2",
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.0-beta.4",
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.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"
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.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"
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
- /** Assert a section object (ns + fields) matches the contract. */
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: statusModule.getHostStatusStore(runtime)?.getSnapshot(),
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
- while (
119
- (state.pinkBinds.length === 0 ||
120
- !state.snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY) ||
121
- state.settingsSection === undefined) &&
122
- Date.now() < deadline
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
- console.log(`OK headless order: ${pinkBinds.length} ledger bind(s), status contribution, settings section`)
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
- const testUtils = await import(pathToFileURL(join(adapter, '..', 'test-utils.js')).href)
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 manifest = testUtils.testManifest({ id: SETTINGS_NAMESPACE })
47
- const admitted = await testUtils.mountAdmitted(app, SETTINGS_NAMESPACE, manifest)
48
- await admitted.context.plugin(pink)
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 testUtils.sleep(25)
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
- 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)
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 testUtils.sleep(1_700)
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 admitted.fiber.dispose()
78
- await testUtils.sleep(50)
110
+ await mount.fiber.dispose()
111
+ await sleep(50)
79
112
  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'])
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 admitted2 = await testUtils.mountAdmitted(app2, SETTINGS_NAMESPACE, manifest)
110
- await admitted2.context.plugin(pink)
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 testUtils.sleep(25)
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
- await admitted2.fiber.dispose()
124
- await testUtils.sleep(50)
125
- console.log('OK toast: shadow hint delivered through the real tuiToast seam')
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 admitted3 = await testUtils.mountAdmitted(app3, SETTINGS_NAMESPACE, manifest)
222
+ const mount3 = await mountPlugin(app3)
161
223
  app3.provide('settings', fakeSettings)
162
- await admitted3.context.plugin(pink)
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 testUtils.sleep(25)
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,80 @@ 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 admitted3.fiber.dispose()
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')
243
+
244
+ // ── Phase 4: settings namespace registers against the REAL service, late ────
245
+ // The stub suites cover the namespace registration with fakes, and phase 3
246
+ // stands in a minimal fake at apply time; this phase mounts a real
247
+ // @deepseek-ai/dsh-settings SettingsProvider AFTER the plugin (and after the
248
+ // extensions row), so the plugin's parked ['settings'] inject fires against
249
+ // the genuine register()/scope/watch machinery. The end-to-end signal: a
250
+ // user-layer followSystem write through the real service flips theme.json.
251
+ const sandbox4 = mkdtempSync(join(tmpdir(), 'pink-settings-late-'))
252
+ process.env.USERPROFILE = sandbox4
253
+ process.env.HOME = sandbox4
254
+ const dataDir4 = join(sandbox4, '.dsh-tui')
255
+ mkdirSync(dataDir4, { recursive: true })
256
+ writeFileSync(join(dataDir4, 'theme-follow.json'), JSON.stringify({ light: true, at: 1 }, null, 2))
257
+
258
+ const { SettingsProvider } = await import(pathToFileURL(hostRequire.resolve('@deepseek-ai/dsh-settings')).href)
259
+
260
+ /** Minimal concrete provider: in-memory storage, no file, no background IO. */
261
+ class MemorySettingsProvider extends SettingsProvider {
262
+ static provide = 'settings'
263
+ writable = true
264
+ stored = {}
265
+ async load() {
266
+ return { ...this.stored }
267
+ }
268
+
269
+ async persist(ns, section) {
270
+ this.stored[ns] = { ...section }
271
+ }
272
+ }
273
+
274
+ const app4 = new Context()
275
+ await app4.plugin(pluginHost.default ?? pluginHost)
276
+ const mount4 = await mountPlugin(app4)
277
+ await mount4.context.plugin(pink)
278
+ await app4.plugin(extensions.default ?? extensions)
279
+ // Deliberately last: the namespace registration can only be "late" when the
280
+ // service arrives after everything the plugin injects at apply time.
281
+ await app4.plugin(MemorySettingsProvider)
282
+
283
+ const provider = app4.get('settings')
284
+ assert.ok(provider, 'the real dsh-settings provider must be mounted')
285
+ const settingsDeadline = Date.now() + 5_000
286
+ while (provider.get(SETTINGS_NAMESPACE) === undefined && Date.now() < settingsDeadline) {
287
+ await sleep(25)
288
+ }
289
+ assert.ok(
290
+ provider.get(SETTINGS_NAMESPACE),
291
+ `the plugin namespace must register with the real service within ${5_000}ms of its late arrival`,
292
+ )
293
+ await provider.update(SETTINGS_NAMESPACE, { followSystem: true })
294
+ assert.equal(
295
+ provider.get(SETTINGS_NAMESPACE)?.followSystem,
296
+ true,
297
+ 'the real service must resolve the committed user layer for the namespace',
298
+ )
299
+
300
+ const prefPath4 = join(dataDir4, 'theme.json')
301
+ const followDeadline = Date.now() + 5_000
302
+ let followed = false
303
+ while (!followed && Date.now() < followDeadline) {
304
+ try {
305
+ followed = JSON.parse(readFileSync(prefPath4, 'utf8')).theme === 'pink-day'
306
+ } catch {
307
+ // Pref not written yet.
308
+ }
309
+ if (!followed) await sleep(25)
310
+ }
311
+ assert.equal(
312
+ followed,
313
+ true,
314
+ 'the user-layer follow toggle must apply the cached light background through the real service',
315
+ )
316
+ await mount4.fiber.dispose()
317
+ console.log('OK settings: namespace registers late on the real dsh-settings service and drives the follow pref')