@promptctl/cc-candybar 1.26.0 → 1.28.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 +86 -85
- package/package.json +6 -6
- package/schema/cc-candybar.schema.json +193 -4
- package/src/check.ts +49 -27
- package/src/click/wire.ts +16 -0
- package/src/config/action.ts +57 -22
- package/src/config/default-dsl-config.ts +424 -55
- package/src/config/dsl-loader.ts +14 -2
- package/src/config/dsl-types.ts +59 -0
- package/src/config/loader/actions.ts +283 -109
- package/src/config/loader/cross-ref.ts +148 -28
- package/src/config/loader/emit-schema.ts +2 -0
- package/src/config/loader/globals.ts +118 -31
- package/src/config/loader/merge.ts +58 -1
- package/src/config/loader/persist-target.ts +32 -0
- package/src/config/loader/presets.ts +107 -0
- package/src/config/option-domain.ts +164 -0
- package/src/config/presets.ts +188 -0
- package/src/daemon/cache/git.ts +1 -1
- package/src/daemon/cache/render.ts +72 -7
- package/src/daemon/config-overrides-store.ts +322 -0
- package/src/daemon/paths.ts +10 -0
- package/src/daemon/render-payload.ts +84 -19
- package/src/daemon/server.ts +68 -55
- package/src/daemon/verbs/config-validators.ts +127 -0
- package/src/daemon/verbs/index.ts +129 -2
- package/src/daemon/verbs/state-validators.ts +98 -586
- package/src/daemon/verbs/validator-registry.ts +457 -0
- package/src/demo/dsl.ts +17 -10
- package/src/dsl/node-registry.ts +54 -39
- package/src/dsl/render.ts +158 -46
- package/src/help-text.ts +3 -3
- package/src/install/index.ts +2 -2
- package/src/render/action.ts +155 -33
- package/src/render/active-segment.ts +78 -0
- package/src/render/menu.ts +16 -11
- package/src/render/picker.ts +51 -13
- package/src/render/segment-color.ts +74 -0
- package/src/segments/git.ts +389 -48
- package/src/template-engine/colors.ts +67 -45
- package/src/template-engine/engine.ts +11 -12
- package/src/themes/index.ts +1 -4
- package/src/themes/palette-resolvers.ts +22 -30
- package/src/themes/policy.ts +37 -16
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// [LAW:one-type-per-behavior] The keyed-validator-registry ALGEBRA, extracted
|
|
2
|
+
// from state-validators.ts (candybar-config-engine-71o.2) so it has exactly
|
|
3
|
+
// ONE implementation shared by two independent keyspaces: SessionState writes
|
|
4
|
+
// (`set` actions, state-validators.ts) and persistent config writes (`persist`
|
|
5
|
+
// actions, config-validators.ts). What differs between the two is only DATA —
|
|
6
|
+
// which keys are baseline/permanent and what namespace the keys live in — so
|
|
7
|
+
// this module is the "one cutter" and each keyspace is an instance of it, not
|
|
8
|
+
// a hand-rolled copy of the merge/dispose/rebuild logic.
|
|
9
|
+
//
|
|
10
|
+
// [LAW:one-source-of-truth] THE spec algebra: a key's live registrations
|
|
11
|
+
// (DerivedValidatorSpec[]) collapse to ONE spec via mergeKeySpecs, and a spec
|
|
12
|
+
// is residue-projected to a KeyValidator via validatorForSpec. Both keyspaces
|
|
13
|
+
// read this from the SAME functions, so "what does a range/allow-list/int
|
|
14
|
+
// spec mean" cannot drift between session and config gates.
|
|
15
|
+
|
|
16
|
+
// [LAW:types-are-the-program] Discriminated union — every legal return is
|
|
17
|
+
// either an accepted-and-canonicalized string or a structured rejection
|
|
18
|
+
// reason. There is no third state (no `null`, no thrown exception path
|
|
19
|
+
// inside a validator). The verb body matches exhaustively on `ok`.
|
|
20
|
+
export type ValidateResult =
|
|
21
|
+
| { ok: true; value: string }
|
|
22
|
+
| { ok: false; reason: string };
|
|
23
|
+
|
|
24
|
+
// [LAW:one-type-per-behavior] All key validators have the same shape — they
|
|
25
|
+
// don't carry the key name, the registry does. The validator's only concern
|
|
26
|
+
// is: does this raw string belong in this key's value-set?
|
|
27
|
+
export type KeyValidator = (rawValue: string) => ValidateResult;
|
|
28
|
+
|
|
29
|
+
// [LAW:types-are-the-program] A derived key's SEMANTIC identity — the data an
|
|
30
|
+
// action's `set`/`persist` declares about a key, from which the validator is
|
|
31
|
+
// residue. A key is one of three key shapes: an integer (a menu's page
|
|
32
|
+
// index), an allow-list (the union of values some button can write), or a
|
|
33
|
+
// bounded integer range (a stepper's value). The registry compares specs to
|
|
34
|
+
// decide whether two registrations can share a key (same `kind`) and merges
|
|
35
|
+
// them by unioning content (allow-list members; range bounds); the opaque
|
|
36
|
+
// `KeyValidator` it builds from the spec cannot be compared or merged, which
|
|
37
|
+
// is why registration takes the spec and owns validator construction.
|
|
38
|
+
//
|
|
39
|
+
// [LAW:one-source-of-truth] The spec carries only content (kind + allow-list
|
|
40
|
+
// members + range bounds), never the human label — the label is a pure
|
|
41
|
+
// function of the key, computed where the validator is built, so two
|
|
42
|
+
// registrations of one key yield byte-identical validators regardless of
|
|
43
|
+
// which config registered first.
|
|
44
|
+
export type DerivedValidatorSpec =
|
|
45
|
+
| { readonly kind: "int" }
|
|
46
|
+
| { readonly kind: "allow-list"; readonly allowed: readonly string[] }
|
|
47
|
+
| {
|
|
48
|
+
// [LAW:one-source-of-truth] A bounded-integer state key (a stepper's
|
|
49
|
+
// value). `min`/`max` gate the value; `seed` is the value an UNSET key
|
|
50
|
+
// reads as — sourced from the backing default so the first relative
|
|
51
|
+
// click steps from the same number the bar displays (not silently from
|
|
52
|
+
// `min`). The validator ignores `seed` (it only clamps); the caller
|
|
53
|
+
// reads it via rangeParamsFor when the key is unset.
|
|
54
|
+
readonly kind: "range";
|
|
55
|
+
readonly min: number;
|
|
56
|
+
readonly max: number;
|
|
57
|
+
readonly seed: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// [LAW:one-source-of-truth] One contribution shape — a (key, spec) pair —
|
|
61
|
+
// every action's write declaration projects to. mergeContributions folds a
|
|
62
|
+
// list of these into the final per-key validator specs, so multiple actions
|
|
63
|
+
// writing one key feed ONE coherence merge regardless of which action
|
|
64
|
+
// authored the write.
|
|
65
|
+
export interface KeySpecContribution {
|
|
66
|
+
readonly key: string;
|
|
67
|
+
readonly spec: DerivedValidatorSpec;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const INT_RE = /^-?\d+$/;
|
|
71
|
+
|
|
72
|
+
// [LAW:one-source-of-truth] The unset seed for a stepped key is the backing
|
|
73
|
+
// default — the SAME number the bar displays before the first click — so the
|
|
74
|
+
// first relative step doesn't silently start from `min`. Absent or
|
|
75
|
+
// non-integer default falls back to `min` (the historical render-side
|
|
76
|
+
// behavior).
|
|
77
|
+
export function clampSeed(
|
|
78
|
+
seed: number | undefined,
|
|
79
|
+
min: number,
|
|
80
|
+
max: number,
|
|
81
|
+
): number {
|
|
82
|
+
if (seed === undefined) return min;
|
|
83
|
+
return Math.max(min, Math.min(max, seed));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// [LAW:one-type-per-behavior] The "values come from list Y" pattern IS the
|
|
87
|
+
// canonical widget-config use case (theme picker draws from themes(), style
|
|
88
|
+
// picker draws from styles(), a custom enum picker draws from a
|
|
89
|
+
// user-declared list). One factory builds the validator from the list —
|
|
90
|
+
// every callsite that registers an allow-list key passes through the same
|
|
91
|
+
// shape, so error messages, empty-input rejection, and lookup semantics are
|
|
92
|
+
// identical by construction.
|
|
93
|
+
//
|
|
94
|
+
// [LAW:no-silent-fallbacks] Empty input is rejected with a label-referencing
|
|
95
|
+
// reason rather than silently mapped to a default.
|
|
96
|
+
//
|
|
97
|
+
// [LAW:one-source-of-truth] `wire` names the ACTUAL wire this allow-list's
|
|
98
|
+
// values travel over — "set-state" for SessionState keys, "set-config" for
|
|
99
|
+
// config-overrides keys — so the slash-rejection message points at the wire
|
|
100
|
+
// the operator is actually debugging. Defaults to "set-state" (this
|
|
101
|
+
// factory's original, sole caller) so existing direct callers (tests) don't
|
|
102
|
+
// need to pass it; validatorForSpec passes the correct wire for its noun.
|
|
103
|
+
export function makeAllowListValidator(
|
|
104
|
+
allowed: readonly string[],
|
|
105
|
+
label: string,
|
|
106
|
+
wire: string = "set-state",
|
|
107
|
+
): KeyValidator {
|
|
108
|
+
// [LAW:types-are-the-program] The factory's contract is "options = allow
|
|
109
|
+
// list" — every value the picker can RENDER must also be a value the wire
|
|
110
|
+
// can DELIVER. Two structural reasons a declared option can't reach the
|
|
111
|
+
// validator as itself: (1) the wire splits the tail on "/"; (2) the
|
|
112
|
+
// validator's empty-input rejection fires before the allow-list check, so
|
|
113
|
+
// an "" in the allow list would be listed-but-undeliverable. Catching at
|
|
114
|
+
// factory-build time (config-load) surfaces a misconfigured option list
|
|
115
|
+
// immediately, not on the operator's first click.
|
|
116
|
+
const slashOffenders = allowed.filter((v) => v.includes("/"));
|
|
117
|
+
if (slashOffenders.length > 0) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`makeAllowListValidator(${label}): values contain "/" — the ${wire} ` +
|
|
120
|
+
`wire shape splits values on "/" so slash-bearing options cannot ` +
|
|
121
|
+
`be addressed. Offending values: ${slashOffenders.join(", ")}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (allowed.includes("")) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`makeAllowListValidator(${label}): empty string is not a writable ` +
|
|
127
|
+
`option — the validator rejects empty input before the allow-list ` +
|
|
128
|
+
`check, so an "" in the allowed list could be rendered but never ` +
|
|
129
|
+
`delivered. Remove "" from the allowed list.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const allowedSet: ReadonlySet<string> = new Set(allowed);
|
|
133
|
+
const allowedList = [...allowed];
|
|
134
|
+
return (raw) => {
|
|
135
|
+
if (!raw) return { ok: false, reason: `${label} value is required` };
|
|
136
|
+
if (!allowedSet.has(raw)) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
reason: `unknown ${label} "${raw}" (have: ${allowedList.join(", ")})`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, value: raw };
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// [LAW:types-are-the-program] An integer-valued state key (a menu's page
|
|
147
|
+
// index). The wire delivers a string; the validator IS the parse boundary —
|
|
148
|
+
// it accepts only `^-?\d+$` and canonicalizes to the minimal decimal form.
|
|
149
|
+
// Negative is legal: -1 is the menu's CLOSED sentinel.
|
|
150
|
+
export function makeIntValidator(label: string): KeyValidator {
|
|
151
|
+
return (raw) => {
|
|
152
|
+
if (!raw) return { ok: false, reason: `${label} value is required` };
|
|
153
|
+
if (!INT_RE.test(raw)) {
|
|
154
|
+
return { ok: false, reason: `${label} must be an integer, got "${raw}"` };
|
|
155
|
+
}
|
|
156
|
+
const neg = raw[0] === "-";
|
|
157
|
+
const digits = (neg ? raw.slice(1) : raw).replace(/^0+/, "");
|
|
158
|
+
if (digits === "") return { ok: true, value: "0" };
|
|
159
|
+
return { ok: true, value: neg ? `-${digits}` : digits };
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// [LAW:types-are-the-program] A bounded-integer state key (a stepper's
|
|
164
|
+
// value). The validator is the parse-AND-clamp boundary: it accepts only
|
|
165
|
+
// `^-?\d+$` then clamps into [min,max]. [LAW:single-enforcer] This is the ONE
|
|
166
|
+
// place bounds are enforced.
|
|
167
|
+
export function makeRangeValidator(
|
|
168
|
+
min: number,
|
|
169
|
+
max: number,
|
|
170
|
+
label: string,
|
|
171
|
+
): KeyValidator {
|
|
172
|
+
return (raw) => {
|
|
173
|
+
if (!raw) return { ok: false, reason: `${label} value is required` };
|
|
174
|
+
if (!INT_RE.test(raw)) {
|
|
175
|
+
return { ok: false, reason: `${label} must be an integer, got "${raw}"` };
|
|
176
|
+
}
|
|
177
|
+
const clamped = Math.max(min, Math.min(max, parseInt(raw, 10)));
|
|
178
|
+
return { ok: true, value: String(clamped) };
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// [LAW:types-are-the-program] Collapse one key's spec contributions into the
|
|
183
|
+
// single spec that gates it. A key is an INTEGER spec (a paged cursor `int`
|
|
184
|
+
// or a bounded `range`) or an allow-list — never both. An integer spec
|
|
185
|
+
// ABSORBS integer allow-list members (a trigger writing "0" to a page cursor
|
|
186
|
+
// is a legal int write), and a NON-integer member aimed at it is the genuine
|
|
187
|
+
// contradiction that throws. Two ranges widen-union; two allow-lists union;
|
|
188
|
+
// an int and a range on one key conflict.
|
|
189
|
+
// [LAW:one-source-of-truth] `noun` ("state"/"config") names the keyspace in
|
|
190
|
+
// every thrown message — this function is the SAME merge both
|
|
191
|
+
// deriveActionValidators (state-validators.ts) and deriveConfigActionValidators
|
|
192
|
+
// (config-validators.ts) call, so a conflict thrown while merging a `persist`
|
|
193
|
+
// action's contributions must say "config", never the SessionState-era
|
|
194
|
+
// "state" wording (or the operator debugging a persist action gets pointed at
|
|
195
|
+
// the wrong keyspace's mental model).
|
|
196
|
+
export function mergeKeySpecs(
|
|
197
|
+
key: string,
|
|
198
|
+
specs: readonly DerivedValidatorSpec[],
|
|
199
|
+
noun: string = "state",
|
|
200
|
+
): DerivedValidatorSpec {
|
|
201
|
+
type Range = Extract<DerivedValidatorSpec, { kind: "range" }>;
|
|
202
|
+
const ranges = specs.filter((s): s is Range => s.kind === "range");
|
|
203
|
+
const hasInt = specs.some((s) => s.kind === "int");
|
|
204
|
+
const allowed = specs.flatMap((s) =>
|
|
205
|
+
s.kind === "allow-list" ? s.allowed : [],
|
|
206
|
+
);
|
|
207
|
+
if (ranges.length === 0 && !hasInt) {
|
|
208
|
+
return { kind: "allow-list", allowed: [...new Set(allowed)] };
|
|
209
|
+
}
|
|
210
|
+
const nonInt = allowed.filter((v) => !INT_RE.test(v));
|
|
211
|
+
if (nonInt.length > 0) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`${noun} action table: key "${key}" is an integer spec (a paged ` +
|
|
214
|
+
`cursor or a bounded value) but a click writes non-integer ` +
|
|
215
|
+
`value(s) to it (${nonInt.join(", ")}). A ${noun} key has one key ` +
|
|
216
|
+
`shape — point that click at a distinct key, or write an integer.`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (hasInt && ranges.length > 0) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`${noun} action table: key "${key}" is declared as both a paged ` +
|
|
222
|
+
`cursor (int) and a bounded value (range) — a ${noun} key has one key ` +
|
|
223
|
+
`shape. Use distinct keys.`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (ranges.length > 0) {
|
|
227
|
+
const min = Math.min(...ranges.map((r) => r.min));
|
|
228
|
+
const max = Math.max(...ranges.map((r) => r.max));
|
|
229
|
+
const outOfRange = allowed.filter((v) => {
|
|
230
|
+
const n = parseInt(v, 10);
|
|
231
|
+
return n < min || n > max;
|
|
232
|
+
});
|
|
233
|
+
if (outOfRange.length > 0) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`${noun} action table: key "${key}" is a bounded range [${min},${max}] ` +
|
|
236
|
+
`but a click writes out-of-range value(s) to it ` +
|
|
237
|
+
`(${outOfRange.join(", ")}). The range gate would clamp them, storing a ` +
|
|
238
|
+
`different value than the click renders — write an in-range integer, ` +
|
|
239
|
+
`or point that click at a distinct key.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const seed = clampSeed(ranges[0]!.seed, min, max);
|
|
243
|
+
return { kind: "range", min, max, seed };
|
|
244
|
+
}
|
|
245
|
+
return { kind: "int" };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// [LAW:one-source-of-truth] The click-wire verb name a keyspace's writes
|
|
249
|
+
// travel over — "set-state" for the SessionState keyspace, "set-config" for
|
|
250
|
+
// config-overrides. Mirrors loader/actions.ts's wireName (same concept, the
|
|
251
|
+
// loader's discriminator vocabulary is "set"/"persist" instead of
|
|
252
|
+
// "state"/"config").
|
|
253
|
+
function wireForNoun(noun: string): string {
|
|
254
|
+
return noun === "config" ? "set-config" : "set-state";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// [LAW:types-are-the-program] The validator is RESIDUE of a SETTLED spec:
|
|
258
|
+
// given one merged spec, its validator is forced. Pure projection — kind ⇒
|
|
259
|
+
// constructor — with NO union or widen of its own. `noun` ("state"/"config")
|
|
260
|
+
// is threaded through so the SAME shared projection labels a rejection
|
|
261
|
+
// message with the keyspace it actually belongs to — this is the one place
|
|
262
|
+
// that builds every validator, so it is the one place that can misname the
|
|
263
|
+
// keyspace if the noun doesn't ride along.
|
|
264
|
+
function validatorForSpec(
|
|
265
|
+
key: string,
|
|
266
|
+
spec: DerivedValidatorSpec,
|
|
267
|
+
noun: string,
|
|
268
|
+
): KeyValidator {
|
|
269
|
+
if (spec.kind === "int") return makeIntValidator(`menu page "${key}"`);
|
|
270
|
+
if (spec.kind === "range")
|
|
271
|
+
return makeRangeValidator(spec.min, spec.max, `${noun} stepper "${key}"`);
|
|
272
|
+
return makeAllowListValidator(
|
|
273
|
+
spec.allowed,
|
|
274
|
+
`${noun} "${key}"`,
|
|
275
|
+
wireForNoun(noun),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function buildValidatorFromSpecs(
|
|
280
|
+
key: string,
|
|
281
|
+
specs: readonly DerivedValidatorSpec[],
|
|
282
|
+
noun: string,
|
|
283
|
+
): KeyValidator {
|
|
284
|
+
return validatorForSpec(key, mergeKeySpecs(key, specs, noun), noun);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// [LAW:single-enforcer] THE coherence merge: group every contribution by key
|
|
288
|
+
// and collapse each key's specs into the one spec that gates it. `noun`
|
|
289
|
+
// names the keyspace (default "state" — the original, sole caller before
|
|
290
|
+
// config-validators.ts's twin) so a conflict thrown mid-merge for a
|
|
291
|
+
// `persist` action's contributions names the config keyspace, not state.
|
|
292
|
+
export function mergeContributions(
|
|
293
|
+
contributions: readonly KeySpecContribution[],
|
|
294
|
+
noun: string = "state",
|
|
295
|
+
): KeySpecContribution[] {
|
|
296
|
+
const byKey = new Map<string, DerivedValidatorSpec[]>();
|
|
297
|
+
for (const { key, spec } of contributions) {
|
|
298
|
+
const specs = byKey.get(key);
|
|
299
|
+
if (specs) specs.push(spec);
|
|
300
|
+
else byKey.set(key, [spec]);
|
|
301
|
+
}
|
|
302
|
+
return [...byKey].map(([key, specs]) => ({
|
|
303
|
+
key,
|
|
304
|
+
spec: mergeKeySpecs(key, specs, noun),
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface RangeParams {
|
|
309
|
+
readonly min: number;
|
|
310
|
+
readonly max: number;
|
|
311
|
+
readonly seed: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
interface BaselineEntry {
|
|
315
|
+
readonly permanent: true;
|
|
316
|
+
readonly validator: KeyValidator;
|
|
317
|
+
}
|
|
318
|
+
interface DerivedEntry {
|
|
319
|
+
readonly permanent: false;
|
|
320
|
+
readonly kind: DerivedValidatorSpec["kind"];
|
|
321
|
+
validator: KeyValidator;
|
|
322
|
+
readonly specs: DerivedValidatorSpec[];
|
|
323
|
+
}
|
|
324
|
+
type ValidatorEntry = BaselineEntry | DerivedEntry;
|
|
325
|
+
|
|
326
|
+
export interface ValidatorRegistry {
|
|
327
|
+
register(key: string, spec: DerivedValidatorSpec): () => void;
|
|
328
|
+
validate(key: string, rawValue: string): ValidateResult;
|
|
329
|
+
listKeys(): readonly string[];
|
|
330
|
+
// [LAW:one-source-of-truth] The permanent/baseline subset of listKeys() —
|
|
331
|
+
// exposed so a consumer that needs to distinguish "derived from an action
|
|
332
|
+
// table" from "always writable" (e.g. dropping an allow-list contribution
|
|
333
|
+
// aimed at a baseline key) reads it from the registry that owns the
|
|
334
|
+
// distinction, rather than re-declaring the baseline set as a second list.
|
|
335
|
+
listBaselineKeys(): readonly string[];
|
|
336
|
+
rangeParamsFor(key: string): RangeParams | null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// [LAW:one-type-per-behavior] ONE registry implementation, instantiated once
|
|
340
|
+
// per keyspace. `baseline` seeds PERMANENT entries (raw KeyValidator
|
|
341
|
+
// functions, never re-claimable — SessionState's legacy theme/style/
|
|
342
|
+
// toolbar-expanded); an empty baseline (config-overrides' keyspace) means
|
|
343
|
+
// every key is fully derived from the action table, exactly the epic's
|
|
344
|
+
// "zero engine edits to add a menu-able field" goal. `noun` names the
|
|
345
|
+
// keyspace in every message ("state"/"config") so the two instances stay
|
|
346
|
+
// operator-distinguishable — a "state key" and "config key" error can never
|
|
347
|
+
// be confused for the other keyspace's gate.
|
|
348
|
+
//
|
|
349
|
+
// [LAW:no-silent-fallbacks] An unknown key is a caller-visible rejection
|
|
350
|
+
// (validate) or a loud throw (a baseline re-claim, a kind clash) — never a
|
|
351
|
+
// silent accept-and-store.
|
|
352
|
+
export function createValidatorRegistry(
|
|
353
|
+
baseline: Readonly<Record<string, KeyValidator>>,
|
|
354
|
+
noun: string = "state",
|
|
355
|
+
): ValidatorRegistry {
|
|
356
|
+
const entries = new Map<string, ValidatorEntry>(
|
|
357
|
+
Object.entries(baseline).map(([key, validator]) => [
|
|
358
|
+
key,
|
|
359
|
+
{ validator, permanent: true } as const,
|
|
360
|
+
]),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
function baselineKeys(): readonly string[] {
|
|
364
|
+
const out: string[] = [];
|
|
365
|
+
for (const [key, entry] of entries) if (entry.permanent) out.push(key);
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
register(key, spec) {
|
|
371
|
+
if (!key) throw new Error("register: key is required");
|
|
372
|
+
// [LAW:types-are-the-program] The set-state wire splits its tail on
|
|
373
|
+
// `/`, so a slash-bearing key can never be addressed — listing it
|
|
374
|
+
// would be registry-vs-wire drift. Reject at registration so the
|
|
375
|
+
// unreachable-but-listed state is unrepresentable.
|
|
376
|
+
if (key.includes("/")) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`register: key "${key}" contains "/" — the wire shape splits on ` +
|
|
379
|
+
`"/" so a slash-bearing key cannot be addressed. Use a slash-free key.`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
const existing = entries.get(key);
|
|
383
|
+
if (existing) {
|
|
384
|
+
if (existing.permanent) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`register: key "${key}" is a built-in ${noun} key and cannot be ` +
|
|
387
|
+
`re-claimed (built-in keys: ${[...baselineKeys()].join(", ")})`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
if (existing.kind !== spec.kind) {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`register: key "${key}" is already a ${existing.kind} ${noun} key; ` +
|
|
393
|
+
`cannot also register it as ${spec.kind}. A ${noun} key has one ` +
|
|
394
|
+
`key shape — a menu page index (int) and a button allow-list ` +
|
|
395
|
+
`cannot share a key.`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
existing.specs.push(spec);
|
|
399
|
+
existing.validator = buildValidatorFromSpecs(key, existing.specs, noun);
|
|
400
|
+
} else {
|
|
401
|
+
const specs = [spec];
|
|
402
|
+
entries.set(key, {
|
|
403
|
+
permanent: false,
|
|
404
|
+
kind: spec.kind,
|
|
405
|
+
validator: buildValidatorFromSpecs(key, specs, noun),
|
|
406
|
+
specs,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
let active = true;
|
|
410
|
+
return () => {
|
|
411
|
+
if (!active) return;
|
|
412
|
+
active = false;
|
|
413
|
+
const entry = entries.get(key);
|
|
414
|
+
if (!entry || entry.permanent) return;
|
|
415
|
+
const i = entry.specs.indexOf(spec);
|
|
416
|
+
if (i >= 0) entry.specs.splice(i, 1);
|
|
417
|
+
if (entry.specs.length === 0) {
|
|
418
|
+
entries.delete(key);
|
|
419
|
+
} else {
|
|
420
|
+
entry.validator = buildValidatorFromSpecs(key, entry.specs, noun);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
validate(key, rawValue) {
|
|
426
|
+
const entry = entries.get(key);
|
|
427
|
+
if (!entry) {
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
reason: `unknown ${noun} key "${key}" (have: ${[...entries.keys()].join(", ")})`,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
return entry.validator(rawValue);
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
listKeys() {
|
|
437
|
+
return [...entries.keys()];
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
listBaselineKeys() {
|
|
441
|
+
return baselineKeys();
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
rangeParamsFor(key) {
|
|
445
|
+
const entry = entries.get(key);
|
|
446
|
+
if (!entry || entry.permanent || entry.kind !== "range") return null;
|
|
447
|
+
const spec = mergeKeySpecs(key, entry.specs, noun);
|
|
448
|
+
if (spec.kind !== "range") {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`rangeParamsFor: key "${key}" holds range specs but the merge ` +
|
|
451
|
+
`produced a ${spec.kind} spec — the entry-kind invariant is broken.`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
return { min: spec.min, max: spec.max, seed: spec.seed };
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
package/src/demo/dsl.ts
CHANGED
|
@@ -34,8 +34,9 @@ import {
|
|
|
34
34
|
effectiveThemeName,
|
|
35
35
|
effectiveLookName,
|
|
36
36
|
lookKeyByName,
|
|
37
|
-
|
|
37
|
+
paletteForThemeName,
|
|
38
38
|
} from "../themes/index.js";
|
|
39
|
+
import { effectivePresetName, presetGlobals } from "../config/presets.js";
|
|
39
40
|
import { registerDslConfig, renderDsl } from "../dsl/render.js";
|
|
40
41
|
import {
|
|
41
42
|
DEFAULT_CHARSET,
|
|
@@ -77,16 +78,22 @@ const payload = {
|
|
|
77
78
|
},
|
|
78
79
|
};
|
|
79
80
|
|
|
80
|
-
// The demo has no SessionState
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// The demo has no SessionState, so every resolution below is the config default
|
|
82
|
+
// over its floor. The PRESET resolves first — its fragment supplies the display
|
|
83
|
+
// globals every other option reads — the same preset-first order server.ts and
|
|
84
|
+
// check.ts resolve in, so the demo prints the arrangement a fresh session opens
|
|
85
|
+
// in.
|
|
86
|
+
const preset = effectivePresetName(null, config.globals.preset, config.presets);
|
|
87
|
+
const globals = presetGlobals(config, preset);
|
|
88
|
+
const basePalette = paletteForThemeName(
|
|
89
|
+
effectiveThemeName(null, globals.palette),
|
|
83
90
|
);
|
|
84
91
|
// Same fresh-session resolution one dimension over: the config-default look
|
|
85
92
|
// over the "none" identity floor — the exact mirror of the daemon's per-render
|
|
86
93
|
// effectiveLookName → lookKeyByName chain.
|
|
87
94
|
const lookKey = lookKeyByName(
|
|
88
95
|
config.looks,
|
|
89
|
-
effectiveLookName(null,
|
|
96
|
+
effectiveLookName(null, globals.look, config.looks),
|
|
90
97
|
);
|
|
91
98
|
|
|
92
99
|
// A fresh store + registry for this run. (A hot-reloading daemon would
|
|
@@ -125,7 +132,7 @@ try {
|
|
|
125
132
|
// Same resolution the daemon applies: the config global over the
|
|
126
133
|
// truecolor default floor.
|
|
127
134
|
colorCompatibility:
|
|
128
|
-
|
|
135
|
+
globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
|
|
129
136
|
// [LAW:one-source-of-truth] Demo applies the same Claude-Code-UI
|
|
130
137
|
// reserve the daemon does so demo output matches the bytes a real
|
|
131
138
|
// statusline would emit at the same terminal width.
|
|
@@ -134,12 +141,12 @@ try {
|
|
|
134
141
|
),
|
|
135
142
|
// Same resolution the daemon applies: the config global over the
|
|
136
143
|
// default-on floor.
|
|
137
|
-
wrap:
|
|
138
|
-
padding:
|
|
139
|
-
charset:
|
|
144
|
+
wrap: globals.autoWrap ?? DEFAULT_WRAP,
|
|
145
|
+
padding: globals.padding ?? DEFAULT_PADDING,
|
|
146
|
+
charset: globals.charset ?? DEFAULT_CHARSET,
|
|
140
147
|
},
|
|
141
148
|
undefined,
|
|
142
|
-
lookKey,
|
|
149
|
+
{ look: lookKey, preset },
|
|
143
150
|
);
|
|
144
151
|
process.stdout.write(` ${line}\n`);
|
|
145
152
|
if (frame < FRAMES - 1) await sleep(FRAME_INTERVAL_MS);
|
package/src/dsl/node-registry.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// strip item), not a function of matching backgrounds.
|
|
24
24
|
|
|
25
25
|
import { RichText, IDENTITY } from "@promptctl/rich-js";
|
|
26
|
-
import type {
|
|
26
|
+
import type { Palette, Style, ThemeKey } from "@promptctl/rich-js";
|
|
27
27
|
import type { Template } from "@promptctl/go-template-js";
|
|
28
28
|
import type {
|
|
29
29
|
LayoutNode,
|
|
@@ -31,12 +31,11 @@ import type {
|
|
|
31
31
|
SegmentDecl,
|
|
32
32
|
} from "../config/dsl-types.js";
|
|
33
33
|
import { splitCellsIntoLines } from "../render/split-lines.js";
|
|
34
|
-
import {
|
|
34
|
+
import { transposedPalette } from "../themes/index.js";
|
|
35
35
|
import {
|
|
36
36
|
fragmentsToCells,
|
|
37
37
|
evaluateWhen,
|
|
38
38
|
applySegmentLayout,
|
|
39
|
-
resolveSegmentColors,
|
|
40
39
|
} from "../template-engine/index.js";
|
|
41
40
|
|
|
42
41
|
// ─── Compiled node shapes ──────────────────────────────────────────────────────
|
|
@@ -66,7 +65,7 @@ export interface CompiledSegment {
|
|
|
66
65
|
readonly template: Template<RichText>;
|
|
67
66
|
readonly bg?: Template<RichText>;
|
|
68
67
|
readonly fg?: Template<RichText>;
|
|
69
|
-
readonly
|
|
68
|
+
readonly palette?: Palette;
|
|
70
69
|
}
|
|
71
70
|
export type CompiledSegments = Readonly<Record<string, CompiledSegment>>;
|
|
72
71
|
|
|
@@ -99,7 +98,7 @@ export interface NodeCompileCtx {
|
|
|
99
98
|
// (the driver ANDs node.when with the parent's). renderChild continues the walk.
|
|
100
99
|
export interface NodeRenderCtx {
|
|
101
100
|
readonly scope: object;
|
|
102
|
-
readonly basePalette:
|
|
101
|
+
readonly basePalette: Palette;
|
|
103
102
|
// [LAW:one-source-of-truth] The render-wide look (the session's chosen
|
|
104
103
|
// theme-adaptation, resolved by the caller via effectiveLookName →
|
|
105
104
|
// lookKeyByName), threaded by the driver — one ThemeKey per render, IDENTITY
|
|
@@ -121,15 +120,28 @@ export interface NodeRenderCtx {
|
|
|
121
120
|
// text verdict instead of blessing a bar it cannot see. Trusted non-throwing
|
|
122
121
|
// (the registry-dispose contract) — see RenderObservers.onSegmentError.
|
|
123
122
|
readonly onSegmentError?: (segName: string, message: string) => void;
|
|
124
|
-
// [LAW:locality-or-seam] The
|
|
125
|
-
// never imports the menu
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
123
|
+
// [LAW:locality-or-seam] The segment seam, injected as a capability pair so
|
|
124
|
+
// this module never imports the menu or color features — it only says when a
|
|
125
|
+
// segment starts and stops.
|
|
126
|
+
//
|
|
127
|
+
// `enterSegment` runs BEFORE any of the segment's templates evaluate. It
|
|
128
|
+
// publishes what the segment's own templates may ask about themselves — the
|
|
129
|
+
// name a `{{ menu }}` derives its identity from, the palette `{{ color }}`
|
|
130
|
+
// resolves against, the background `{{ bgOf }}` returns — and resolves the
|
|
131
|
+
// segment's `bg:`/`fg:` into its base Style along the way. The bg/fg
|
|
132
|
+
// resolution HAS to happen here rather than after the body: a body asking for
|
|
133
|
+
// its own background can only be answered once the background exists.
|
|
134
|
+
//
|
|
135
|
+
// `exitSegment` runs AFTER eval: it reads the open menu bodies the menus
|
|
136
|
+
// carried as metadata on the evaluated fragments (template order) for the
|
|
137
|
+
// boundary to stack below the row, and tears the published record down.
|
|
138
|
+
enterSegment(
|
|
139
|
+
segName: string,
|
|
140
|
+
palette: Palette,
|
|
141
|
+
bgTemplate: Template<RichText> | undefined,
|
|
142
|
+
fgTemplate: Template<RichText> | undefined,
|
|
143
|
+
): Style;
|
|
144
|
+
exitSegment(fragments: readonly RichText[]): readonly RichText[];
|
|
133
145
|
// [LAW:locality-or-seam] The focus transform, injected as a capability (rich-js
|
|
134
146
|
// owns the color math — see render.ts). Applied to the segment's baseStyle when
|
|
135
147
|
// it has an open menu (it contributed drops), so the whole focused segment —
|
|
@@ -261,39 +273,42 @@ const segmentType: NodeType<"segment"> = {
|
|
|
261
273
|
try {
|
|
262
274
|
if (!evaluateWhen(segCompiled.when, ctx.scope)) return [];
|
|
263
275
|
|
|
264
|
-
// [LAW:single-enforcer] Publish the segment name + clear the drop sink
|
|
265
|
-
// BEFORE evaluating the template — a `{{ menu }}` inside reads the name to
|
|
266
|
-
// derive its identity and contributes its open body to the sink.
|
|
267
|
-
ctx.beginSegment(node.name);
|
|
268
|
-
const fragments = segCompiled.template.evaluate(ctx.scope);
|
|
269
|
-
// [LAW:decomposition] The open menu bodies, carried as out-of-band metadata
|
|
270
|
-
// on the evaluated fragments — invisible to the inline render, so a menu can
|
|
271
|
-
// sit anywhere in the template and content after it stays inline. Each
|
|
272
|
-
// becomes one full-width line stacked below the segment's row.
|
|
273
|
-
const drops = ctx.collectDrops(fragments);
|
|
274
|
-
|
|
275
276
|
// [LAW:dataflow-not-control-flow] The per-segment variability is WHICH
|
|
276
|
-
// palette — the base
|
|
277
|
+
// palette — the base palette (per-segment override or basePalette)
|
|
277
278
|
// transposed by the render's look + this segment's hueShift, folded into
|
|
278
279
|
// ONE ThemeKey for a SINGLE transposePalette call (chaining two
|
|
279
280
|
// transpositions would double-pay OKLCH quantization and collide the
|
|
280
|
-
// transpose memo — see
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
segCompiled.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
281
|
+
// transpose memo — see transposedPalette). An explicit per-segment
|
|
282
|
+
// `palette:` pin IGNORES the look, exactly as it ignores the session
|
|
283
|
+
// theme: the pin's presence is the discriminator, and its arm carries the
|
|
284
|
+
// identity look — a value choice, not a skipped operation.
|
|
285
|
+
const lookKey = segCompiled.palette !== undefined ? IDENTITY : ctx.look;
|
|
286
|
+
const palette = transposedPalette(
|
|
287
|
+
segCompiled.palette ?? ctx.basePalette,
|
|
288
|
+
{
|
|
289
|
+
...lookKey,
|
|
290
|
+
hueShift: lookKey.hueShift + hueShift,
|
|
291
|
+
},
|
|
290
292
|
);
|
|
291
|
-
|
|
292
|
-
|
|
293
|
+
|
|
294
|
+
// [LAW:one-source-of-truth] ONE palette for this segment: its `bg:`, its
|
|
295
|
+
// `fg:`, and every `{{ color }}` in its body resolve from this same
|
|
296
|
+
// object. That is the whole reason the segment is entered before its body
|
|
297
|
+
// evaluates rather than after — a body coloured from a palette resolved
|
|
298
|
+
// independently of the cell it sits in is two palettes in one segment,
|
|
299
|
+
// and they diverge the moment a theme, look, or hue shift moves.
|
|
300
|
+
const resolvedStyle = ctx.enterSegment(
|
|
301
|
+
node.name,
|
|
302
|
+
palette,
|
|
293
303
|
segCompiled.bg,
|
|
294
304
|
segCompiled.fg,
|
|
295
|
-
ctx.scope,
|
|
296
305
|
);
|
|
306
|
+
const fragments = segCompiled.template.evaluate(ctx.scope);
|
|
307
|
+
// [LAW:decomposition] The open menu bodies, carried as out-of-band metadata
|
|
308
|
+
// on the evaluated fragments — invisible to the inline render, so a menu can
|
|
309
|
+
// sit anywhere in the template and content after it stays inline. Each
|
|
310
|
+
// becomes one full-width line stacked below the segment's row.
|
|
311
|
+
const drops = ctx.exitSegment(fragments);
|
|
297
312
|
// [LAW:dataflow-not-control-flow] Focus is the PRESENCE of a drop: a segment
|
|
298
313
|
// with an open menu (it contributed a body) lightens, so the whole focused
|
|
299
314
|
// segment — inline trigger + dropped band — reads as highlighted. No state
|