@promptctl/cc-candybar 1.35.0 → 1.36.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/dist/index.mjs +49 -49
- package/package.json +5 -5
- package/src/config/dsl-loader.ts +20 -1
- package/src/config/edit-chrome.ts +62 -22
- package/src/config/loader/cross-ref.ts +44 -0
- package/src/config/loader/edit-mode.ts +14 -0
- package/src/config/settings-menu.ts +373 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.36.0",
|
|
4
4
|
"description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -91,9 +91,9 @@
|
|
|
91
91
|
"mobx": "^6.15.0"
|
|
92
92
|
},
|
|
93
93
|
"optionalDependencies": {
|
|
94
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.36.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.36.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.36.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.36.0"
|
|
98
98
|
}
|
|
99
99
|
}
|
package/src/config/dsl-loader.ts
CHANGED
|
@@ -50,6 +50,8 @@ import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
|
|
|
50
50
|
import { synthesizeMenuDecls } from "./loader/menu-synth.js";
|
|
51
51
|
import { synthesizeEditModeToggle } from "./loader/edit-mode.js";
|
|
52
52
|
import { synthesizeEditChrome } from "./edit-chrome.js";
|
|
53
|
+
import { SETTINGS_NS, synthesizeSettingsMenu } from "./settings-menu.js";
|
|
54
|
+
import { reservedNamespaceCollisions } from "./loader/reserved-namespace.js";
|
|
53
55
|
import { validateActions } from "./loader/actions.js";
|
|
54
56
|
import { validateLooks } from "./loader/looks.js";
|
|
55
57
|
import { validatePresets } from "./loader/presets.js";
|
|
@@ -152,7 +154,12 @@ export function validateConfig(
|
|
|
152
154
|
// into freshly-synthesized segments, actions into freshly-synthesized
|
|
153
155
|
// actions) is correct by construction and does not re-enter cross-ref/
|
|
154
156
|
// cycle checking, exactly as group/menu synthesis's output doesn't either.
|
|
155
|
-
|
|
157
|
+
// [LAW:dataflow-not-control-flow] candybar-settings-ui-aok.1's global settings
|
|
158
|
+
// menu, spliced BEFORE edit chrome so edit chrome walks the final content tree
|
|
159
|
+
// and treats the menu's reserved `settings.` names as chrome-exempt — the
|
|
160
|
+
// full ordering argument lives in settings-menu.ts's header, beside the pass
|
|
161
|
+
// it governs.
|
|
162
|
+
const withChrome = synthesizeEditChrome(synthesizeSettingsMenu(config));
|
|
156
163
|
return withChrome as ValidatedConfig;
|
|
157
164
|
}
|
|
158
165
|
|
|
@@ -295,6 +302,18 @@ function validateTopLevel(
|
|
|
295
302
|
// affordances) runs later, in validateConfig, once the merged/preset-
|
|
296
303
|
// resolved/rootOps-replayed tree exists to derive it from.
|
|
297
304
|
synthesizeEditModeToggle(ctx, out);
|
|
305
|
+
// [LAW:one-source-of-truth] The global settings menu reserves its namespace
|
|
306
|
+
// here and synthesizes NOTHING here: the tree it must be present in only
|
|
307
|
+
// exists after merge (a user `root` replaces the default's), so the artifacts
|
|
308
|
+
// are minted in validateConfig. The reservation is unconditional all the same,
|
|
309
|
+
// mirroring every other namespace above — "you never author settings.*" is a
|
|
310
|
+
// stable contract, not a rule that switches on when the pass happens to fire.
|
|
311
|
+
reservedNamespaceCollisions(
|
|
312
|
+
ctx,
|
|
313
|
+
out,
|
|
314
|
+
SETTINGS_NS,
|
|
315
|
+
"the global settings menu",
|
|
316
|
+
);
|
|
298
317
|
return out;
|
|
299
318
|
}
|
|
300
319
|
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
EDIT_TOGGLE_ACTION,
|
|
41
41
|
} from "./loader/edit-mode.js";
|
|
42
42
|
import { GROUP_NS } from "./loader/layout.js";
|
|
43
|
+
import { SETTINGS_NS } from "./settings-menu.js";
|
|
43
44
|
import {
|
|
44
45
|
menuActionName,
|
|
45
46
|
menuMember,
|
|
@@ -61,7 +62,11 @@ import {
|
|
|
61
62
|
// knowledge, so the banner below is spliced UNCONDITIONALLY for every
|
|
62
63
|
// preset — same shape, every reload — and this predicate is what decides
|
|
63
64
|
// whether it's visible, never a branch in this synthesis pass.
|
|
64
|
-
|
|
65
|
+
// [LAW:one-source-of-truth] Exported: test/helpers/ambient-chrome.ts filters this
|
|
66
|
+
// ensured name out of "what did the AUTHOR declare" assertions and must read the
|
|
67
|
+
// same string, never a second copy that a rename here would leave behind.
|
|
68
|
+
export const PRESET_CUSTOMIZED_VAR = "preset.customized";
|
|
69
|
+
const PRESET_CUSTOMIZED_GATE = `{{ .${PRESET_CUSTOMIZED_VAR} }}`;
|
|
65
70
|
|
|
66
71
|
// [LAW:one-source-of-truth] group/menu-synthesized segments (`groups.`/
|
|
67
72
|
// `menus.`) and edit mode's own trigger/chrome (`edit.`) are structural —
|
|
@@ -73,7 +78,12 @@ function isChromeExempt(name: string): boolean {
|
|
|
73
78
|
return (
|
|
74
79
|
name.startsWith(EDIT_NS) ||
|
|
75
80
|
name.startsWith(MENU_NS) ||
|
|
76
|
-
name.startsWith(GROUP_NS)
|
|
81
|
+
name.startsWith(GROUP_NS) ||
|
|
82
|
+
// The global settings menu is the entry point edit mode is REACHED from
|
|
83
|
+
// (candybar-settings-ui-aok.1) — offering a `-` beside it would let one
|
|
84
|
+
// click delete the door back in, the self-lockout the `toolbar` trigger's
|
|
85
|
+
// placement was chosen to avoid. Structural, like the three above it.
|
|
86
|
+
name.startsWith(SETTINGS_NS)
|
|
77
87
|
);
|
|
78
88
|
}
|
|
79
89
|
|
|
@@ -93,6 +103,11 @@ function escapeTemplateLiteral(s: string): string {
|
|
|
93
103
|
// so cross-preset names (disambiguated by `presetIdent`) can never collide.
|
|
94
104
|
interface ChromeArtifacts {
|
|
95
105
|
readonly variables: Record<string, VariableDecl>;
|
|
106
|
+
// [LAW:no-silent-failure] Declarations this synthesis DEPENDS on rather than
|
|
107
|
+
// OWNS: merged UNDER the config so a user's own declaration of the same name
|
|
108
|
+
// wins, unlike `variables` above, which lives in a reserved namespace no user
|
|
109
|
+
// may write and therefore merges over.
|
|
110
|
+
readonly ensured: Record<string, VariableDecl>;
|
|
96
111
|
readonly actions: Record<string, ActionDeclType>;
|
|
97
112
|
readonly segments: Record<string, SegmentDecl>;
|
|
98
113
|
}
|
|
@@ -219,7 +234,18 @@ function spliceContainer(
|
|
|
219
234
|
posCounter: { n: number },
|
|
220
235
|
): ContainerNode {
|
|
221
236
|
const children: LayoutNode[] = [];
|
|
222
|
-
|
|
237
|
+
// [LAW:one-source-of-truth] The trailing `+`'s position is "after the last
|
|
238
|
+
// CONTENT segment", not "after the last child". Those coincided until a
|
|
239
|
+
// synthesis started appending exempt chrome (the global settings menu,
|
|
240
|
+
// candybar-settings-ui-aok.1) to a row's end, at which point reading the last
|
|
241
|
+
// child silently dropped the row's final insert point — N segments offering
|
|
242
|
+
// only N insert points instead of N+1.
|
|
243
|
+
const lastContent = node.children.reduce(
|
|
244
|
+
(idx, child, i) =>
|
|
245
|
+
child.kind === "segment" && !isChromeExempt(child.name) ? i : idx,
|
|
246
|
+
-1,
|
|
247
|
+
);
|
|
248
|
+
for (const [i, child] of node.children.entries()) {
|
|
223
249
|
if (child.kind === "container") {
|
|
224
250
|
children.push(
|
|
225
251
|
spliceContainer(
|
|
@@ -250,24 +276,19 @@ function spliceContainer(
|
|
|
250
276
|
);
|
|
251
277
|
children.push(child);
|
|
252
278
|
children.push(removeChrome(presetIdent, rootOpsKey, child.name, artifacts));
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
last.name,
|
|
267
|
-
"after",
|
|
268
|
-
artifacts,
|
|
269
|
-
),
|
|
270
|
-
);
|
|
279
|
+
if (i === lastContent) {
|
|
280
|
+
children.push(
|
|
281
|
+
insertChrome(
|
|
282
|
+
presetIdent,
|
|
283
|
+
rootOpsKey,
|
|
284
|
+
String(posCounter.n++),
|
|
285
|
+
domainName,
|
|
286
|
+
child.name,
|
|
287
|
+
"after",
|
|
288
|
+
artifacts,
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
271
292
|
}
|
|
272
293
|
return { ...node, children };
|
|
273
294
|
}
|
|
@@ -301,6 +322,20 @@ function prependCustomizedBanner(
|
|
|
301
322
|
// edited down to zero non-exempt segments (no removeChrome/insertChrome
|
|
302
323
|
// persist actions left to register it) doesn't orphan this exact click.
|
|
303
324
|
artifacts.actions[actionName] = { reset: rootOpsKey };
|
|
325
|
+
// [LAW:one-source-of-truth] The banner reads `.preset.customized`, so THIS
|
|
326
|
+
// pass is what requires that variable — not whichever config happens to
|
|
327
|
+
// declare it. The bundled default does, which is why the dependency stayed
|
|
328
|
+
// invisible until the global settings menu made edit mode reachable from
|
|
329
|
+
// configs that never declared it, and the missing field surfaced as a ⚠ on
|
|
330
|
+
// the bar. Ensured, never overridden: a user declaration of the same name
|
|
331
|
+
// wins (see the merge in synthesizeEditChrome), so this only supplies the
|
|
332
|
+
// floor the synthesis itself depends on.
|
|
333
|
+
artifacts.ensured[PRESET_CUSTOMIZED_VAR] = {
|
|
334
|
+
kind: "input",
|
|
335
|
+
path: PRESET_CUSTOMIZED_VAR,
|
|
336
|
+
type: "boolean",
|
|
337
|
+
default: false,
|
|
338
|
+
};
|
|
304
339
|
const label = escapeTemplateLiteral(presetName);
|
|
305
340
|
artifacts.segments[chromeSegName] = {
|
|
306
341
|
template: `{{ action "${actionName}" "↺ ${label} customized" }}`,
|
|
@@ -391,6 +426,7 @@ export function synthesizeEditChrome(config: DslConfig): DslConfig {
|
|
|
391
426
|
if (!(EDIT_TOGGLE_ACTION in config.actions)) return config;
|
|
392
427
|
const artifacts: ChromeArtifacts = {
|
|
393
428
|
variables: {},
|
|
429
|
+
ensured: {},
|
|
394
430
|
actions: {},
|
|
395
431
|
segments: {},
|
|
396
432
|
};
|
|
@@ -404,7 +440,11 @@ export function synthesizeEditChrome(config: DslConfig): DslConfig {
|
|
|
404
440
|
}
|
|
405
441
|
return {
|
|
406
442
|
...config,
|
|
407
|
-
variables: {
|
|
443
|
+
variables: {
|
|
444
|
+
...artifacts.ensured,
|
|
445
|
+
...config.variables,
|
|
446
|
+
...artifacts.variables,
|
|
447
|
+
},
|
|
408
448
|
actions: { ...config.actions, ...artifacts.actions },
|
|
409
449
|
segments: { ...config.segments, ...artifacts.segments },
|
|
410
450
|
presets,
|
|
@@ -29,6 +29,13 @@ import {
|
|
|
29
29
|
import { listGlobalsFieldNames } from "./globals.js";
|
|
30
30
|
import { parsePersistTarget } from "./persist-target.js";
|
|
31
31
|
import { presetNames } from "../presets.js";
|
|
32
|
+
import {
|
|
33
|
+
canHostSessionState,
|
|
34
|
+
countAnchors,
|
|
35
|
+
isSettingsAnchor,
|
|
36
|
+
SESSION_ID_VAR,
|
|
37
|
+
SETTINGS_ANCHOR,
|
|
38
|
+
} from "../settings-menu.js";
|
|
32
39
|
import { ident } from "../ident.js";
|
|
33
40
|
import { findKeyLine } from "./diagnostics.js";
|
|
34
41
|
import { isPlainObject, type ValidateCtx } from "./validate-core.js";
|
|
@@ -248,11 +255,30 @@ export function validateCrossReferences(
|
|
|
248
255
|
// never a second traversal that could learn a different idea of what a valid
|
|
249
256
|
// layout is. This is what makes `cc-candybar check` catch a preset staging a
|
|
250
257
|
// segment nobody declared.
|
|
258
|
+
//
|
|
259
|
+
// [LAW:single-enforcer] The menu precondition is asked once, of the same
|
|
260
|
+
// predicate synthesizeSettingsMenu gates on, and read by every tree walked.
|
|
261
|
+
const menuWillSynthesize = canHostSessionState(cfg);
|
|
251
262
|
const checkLayoutTree = (
|
|
252
263
|
root: LayoutNode,
|
|
253
264
|
layoutKey: string,
|
|
254
265
|
layoutLine: number | undefined,
|
|
255
266
|
): void => {
|
|
267
|
+
// [LAW:one-source-of-truth] The global settings menu's anchor is a POSITION
|
|
268
|
+
// an author may place and this walk must therefore accept, even though no
|
|
269
|
+
// config declares a segment by that name — synthesizeSettingsMenu provides
|
|
270
|
+
// it unconditionally, immediately after these checks pass. Two placements is
|
|
271
|
+
// the real error: one state key holds one open state, so a second anchor
|
|
272
|
+
// would be a second toggle writing one disclosure, with two bodies claiming
|
|
273
|
+
// to be it. Counted over the SAME census the synthesis reads, so "placed"
|
|
274
|
+
// means one thing [LAW:single-enforcer].
|
|
275
|
+
if (countAnchors(root) > 1) {
|
|
276
|
+
ctx.issues.push({
|
|
277
|
+
path: layoutKey,
|
|
278
|
+
message: `${layoutKey} places the global settings menu anchor "${SETTINGS_ANCHOR}" ${countAnchors(root)} times — it may appear at most once per layout (it is one disclosure, and one state key holds one open state). Remove all but the placement you want; removing every placement puts the menu at its default position.`,
|
|
279
|
+
line: layoutLine,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
256
282
|
for (const node of walkNodes(root)) {
|
|
257
283
|
// [LAW:locality-or-seam] A node's `when` reads the global scope (bare
|
|
258
284
|
// globals + namespaced segment vars) — the same existence-check shape as a
|
|
@@ -263,6 +289,24 @@ export function validateCrossReferences(
|
|
|
263
289
|
});
|
|
264
290
|
}
|
|
265
291
|
if (node.kind !== "segment") continue;
|
|
292
|
+
if (isSettingsAnchor(node.name)) {
|
|
293
|
+
// [LAW:one-source-of-truth] Accepting the anchor asserts that
|
|
294
|
+
// synthesizeSettingsMenu WILL declare a segment by this name, so the
|
|
295
|
+
// acceptance reads the very predicate that pass decides by rather than
|
|
296
|
+
// assuming its answer. When it is false the reference is genuinely
|
|
297
|
+
// dangling, and the error names the unmet precondition: the author
|
|
298
|
+
// placed a documented anchor, they did not typo a segment name, and the
|
|
299
|
+
// generic "does not match any declared segment" would teach the wrong
|
|
300
|
+
// lesson [LAW:no-silent-failure].
|
|
301
|
+
if (!menuWillSynthesize) {
|
|
302
|
+
ctx.issues.push({
|
|
303
|
+
path: layoutKey,
|
|
304
|
+
message: `${layoutKey} places the global settings menu anchor "${SETTINGS_ANCHOR}", but this config declares no "${SESSION_ID_VAR}" variable — the menu is a click surface and every click composes a URL from "${SESSION_ID_VAR}", so it is not synthesized for a config without it. Declare a "${SESSION_ID_VAR}" variable (any config merged onto the bundled default inherits one) or remove the anchor placement.`,
|
|
305
|
+
line: layoutLine,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
266
310
|
if (!Object.prototype.hasOwnProperty.call(cfg.segments, node.name)) {
|
|
267
311
|
const renamed = RENAMED_SEGMENTS[node.name];
|
|
268
312
|
const hint =
|
|
@@ -29,6 +29,20 @@
|
|
|
29
29
|
// of this pass caused. The reserved namespace stays reserved unconditionally
|
|
30
30
|
// (mirroring reservedNamespaceCollisions' own contract); only the SYNTHESIS is
|
|
31
31
|
// conditional.
|
|
32
|
+
//
|
|
33
|
+
// WHAT candybar-settings-ui-aok.1 CHANGED, and what it did not: the gate above
|
|
34
|
+
// is intact and still the only way edit mode is reached — but it now has a
|
|
35
|
+
// PERMANENT DEMANDER. synthesizeSettingsMenu mints a `settings.edit` segment
|
|
36
|
+
// referencing `edit.toggle` into every config it can host, so in practice the
|
|
37
|
+
// demand is satisfied for essentially every config a user writes, and reading
|
|
38
|
+
// this section as "most bars carry no edit mode" is no longer true. The
|
|
39
|
+
// separation the gate protects still holds exactly where it always mattered:
|
|
40
|
+
// the menu declines to synthesize for a config with no `session.id`
|
|
41
|
+
// (canHostSessionState in settings-menu.ts), which is precisely the static,
|
|
42
|
+
// non-interactive bar this comment was written to keep clean. Production blast
|
|
43
|
+
// radius was nil either way — the bundled default's `toolbar` segment already
|
|
44
|
+
// referenced `edit.toggle`, so every config merging it already demanded edit
|
|
45
|
+
// mode before the menu existed.
|
|
32
46
|
|
|
33
47
|
import { createEngine } from "@promptctl/go-template-js";
|
|
34
48
|
import type { Mutable, ValidateCtx } from "./validate-core.js";
|