@promptctl/cc-candybar 1.25.0 → 1.27.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 +83 -76
- 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 +156 -0
- package/src/daemon/cache/git.ts +1 -1
- package/src/daemon/cache/render.ts +61 -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 +59 -0
- package/src/index.ts +2 -47
- package/src/install/index.ts +18 -4
- 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
package/src/render/action.ts
CHANGED
|
@@ -25,17 +25,17 @@ import type { FuncMap, Template } from "@promptctl/go-template-js";
|
|
|
25
25
|
import type { VariableStore } from "../var-system/store.js";
|
|
26
26
|
import { toString as varToString } from "../var-system/types.js";
|
|
27
27
|
import { buildScope } from "../template-engine/scope.js";
|
|
28
|
-
import type { ActionDecl
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
STRIP_STYLES,
|
|
32
|
-
type StripStyle,
|
|
33
|
-
} from "../themes/policy.js";
|
|
28
|
+
import type { ActionDecl } from "../config/action.js";
|
|
29
|
+
import { resolveOptionDomain } from "../config/option-domain.js";
|
|
30
|
+
import type { StripStyle } from "../themes/policy.js";
|
|
34
31
|
import {
|
|
35
32
|
effectsUrl,
|
|
36
33
|
VERB_COPY,
|
|
37
34
|
VERB_OPEN_VSCODE,
|
|
35
|
+
VERB_RESET_CONFIG,
|
|
36
|
+
VERB_SET_CONFIG,
|
|
38
37
|
VERB_SET_STATE,
|
|
38
|
+
VERB_STEP_CONFIG,
|
|
39
39
|
VERB_STEP_STATE,
|
|
40
40
|
type Effect,
|
|
41
41
|
} from "../click/wire.js";
|
|
@@ -102,24 +102,65 @@ export type CompiledActionDecl =
|
|
|
102
102
|
readonly members: readonly string[];
|
|
103
103
|
}
|
|
104
104
|
| { readonly kind: "copy"; readonly text: Template<RichText> }
|
|
105
|
-
| { readonly kind: "open"; readonly target: Template<RichText> }
|
|
105
|
+
| { readonly kind: "open"; readonly target: Template<RichText> }
|
|
106
|
+
// [LAW:one-source-of-truth] `persist`'s twin of the set-* kinds above,
|
|
107
|
+
// MINUS set-int (a page cursor is never persisted — see action.ts). Carries
|
|
108
|
+
// the SAME shapes for the SAME reason: a persistent write is gated and
|
|
109
|
+
// realized exactly like a session write, only the wire verb (VERB_SET_CONFIG/
|
|
110
|
+
// VERB_STEP_CONFIG) and the write's durability differ.
|
|
111
|
+
| {
|
|
112
|
+
readonly kind: "persist-literal";
|
|
113
|
+
readonly key: string;
|
|
114
|
+
readonly value: string;
|
|
115
|
+
readonly stateVar: string;
|
|
116
|
+
}
|
|
117
|
+
| {
|
|
118
|
+
readonly kind: "persist-option";
|
|
119
|
+
readonly key: string;
|
|
120
|
+
readonly stateVar: string;
|
|
121
|
+
readonly options: readonly string[];
|
|
122
|
+
}
|
|
123
|
+
| {
|
|
124
|
+
readonly kind: "persist-bounded";
|
|
125
|
+
readonly key: string;
|
|
126
|
+
readonly by: number;
|
|
127
|
+
}
|
|
128
|
+
| {
|
|
129
|
+
readonly kind: "persist-cycle";
|
|
130
|
+
readonly key: string;
|
|
131
|
+
readonly stateVar: string;
|
|
132
|
+
readonly members: readonly string[];
|
|
133
|
+
}
|
|
134
|
+
// [LAW:one-source-of-truth] The gated undo for a persistent write: clears
|
|
135
|
+
// one config-overrides key. Carries only the key — there is no value to
|
|
136
|
+
// realize, so it shares copy/open's "no gate" shape at compile time (the
|
|
137
|
+
// GATE is the key-membership check the reset-config verb handler applies).
|
|
138
|
+
| { readonly kind: "reset"; readonly key: string };
|
|
106
139
|
|
|
107
140
|
export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
|
|
108
141
|
|
|
109
|
-
// [LAW:one-source-of-truth]
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// daemon
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
142
|
+
// [LAW:one-source-of-truth] Globals fields whose CURRENT resolved value is
|
|
143
|
+
// exposed to templates under a different var name than the field itself (the
|
|
144
|
+
// daemon publishes this resolution once per render — e.g. `theme.effective`
|
|
145
|
+
// for `palette`, src/daemon/render-payload.ts). A `persist` action with no
|
|
146
|
+
// entry here reads back through its own key name as an input var (mirrors
|
|
147
|
+
// compileActions' stateKeyToVar fallback), so every persistable globals field
|
|
148
|
+
// needs an entry unless its `.effective` projection happens to be named
|
|
149
|
+
// exactly the bare field (none are — every projection carries the
|
|
150
|
+
// `.effective` suffix). Every field with a projection is listed
|
|
151
|
+
// (candybar-config-engine-71o.3 added style/charset/colorCompatibility/
|
|
152
|
+
// autoWrap/padding to palette/look's original two); a field with no entry
|
|
153
|
+
// here still writes correctly on `persist` — only its "current selection"
|
|
154
|
+
// highlight is inert (readVar falls back to "" since no such var exists).
|
|
155
|
+
const CONFIG_KEY_TO_EFFECTIVE_VAR: ReadonlyMap<string, string> = new Map([
|
|
156
|
+
["palette", "theme.effective"],
|
|
157
|
+
["look", "look.effective"],
|
|
158
|
+
["style", "style.effective"],
|
|
159
|
+
["charset", "charset.effective"],
|
|
160
|
+
["colorCompatibility", "colorCompatibility.effective"],
|
|
161
|
+
["autoWrap", "autoWrap.effective"],
|
|
162
|
+
["padding", "padding.effective"],
|
|
163
|
+
]);
|
|
123
164
|
|
|
124
165
|
// [LAW:locality-or-seam] The runtime holder the `action` template function closes
|
|
125
166
|
// over. Populated after the engine is constructed (the func references the
|
|
@@ -165,13 +206,17 @@ export function compileActions(
|
|
|
165
206
|
parse: (src: string) => Template<RichText>,
|
|
166
207
|
actions: Readonly<Record<string, ActionDecl>>,
|
|
167
208
|
stateKeyToVar: ReadonlyMap<string, string>,
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
209
|
+
// This config's per-config option domains (currently just "looks" — the
|
|
210
|
+
// config's merged look names) — resolveOptionDomain checks these before
|
|
211
|
+
// falling back to the global registry (themes/styles).
|
|
212
|
+
perConfigDomains: ReadonlyMap<string, readonly string[]>,
|
|
171
213
|
): CompiledActions {
|
|
172
214
|
const out = new Map<string, CompiledActionDecl>();
|
|
173
215
|
for (const [name, action] of Object.entries(actions)) {
|
|
174
|
-
out.set(
|
|
216
|
+
out.set(
|
|
217
|
+
name,
|
|
218
|
+
compileAction(parse, name, action, stateKeyToVar, perConfigDomains),
|
|
219
|
+
);
|
|
175
220
|
}
|
|
176
221
|
return out;
|
|
177
222
|
}
|
|
@@ -185,7 +230,7 @@ function compileAction(
|
|
|
185
230
|
name: string,
|
|
186
231
|
action: ActionDecl,
|
|
187
232
|
stateKeyToVar: ReadonlyMap<string, string>,
|
|
188
|
-
|
|
233
|
+
perConfigDomains: ReadonlyMap<string, readonly string[]>,
|
|
189
234
|
): CompiledActionDecl {
|
|
190
235
|
if ("set" in action) {
|
|
191
236
|
const stateVar = stateKeyToVar.get(action.set) ?? action.set;
|
|
@@ -202,7 +247,7 @@ function compileAction(
|
|
|
202
247
|
kind: "set-option",
|
|
203
248
|
key: action.set,
|
|
204
249
|
stateVar,
|
|
205
|
-
options: [...
|
|
250
|
+
options: [...resolveOptionDomain(action.from, perConfigDomains)],
|
|
206
251
|
};
|
|
207
252
|
}
|
|
208
253
|
if ("int" in action) {
|
|
@@ -222,16 +267,52 @@ function compileAction(
|
|
|
222
267
|
by: action.by,
|
|
223
268
|
};
|
|
224
269
|
}
|
|
270
|
+
if ("persist" in action) {
|
|
271
|
+
const stateVar =
|
|
272
|
+
CONFIG_KEY_TO_EFFECTIVE_VAR.get(action.persist) ?? action.persist;
|
|
273
|
+
if ("to" in action) {
|
|
274
|
+
return {
|
|
275
|
+
kind: "persist-literal",
|
|
276
|
+
key: action.persist,
|
|
277
|
+
value: action.to,
|
|
278
|
+
stateVar,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if ("from" in action) {
|
|
282
|
+
return {
|
|
283
|
+
kind: "persist-option",
|
|
284
|
+
key: action.persist,
|
|
285
|
+
stateVar,
|
|
286
|
+
options: [...resolveOptionDomain(action.from, perConfigDomains)],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if ("cycle" in action) {
|
|
290
|
+
return {
|
|
291
|
+
kind: "persist-cycle",
|
|
292
|
+
key: action.persist,
|
|
293
|
+
stateVar,
|
|
294
|
+
members: action.cycle,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
kind: "persist-bounded",
|
|
299
|
+
key: action.persist,
|
|
300
|
+
by: action.by,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
225
303
|
if ("copy" in action) {
|
|
226
304
|
return {
|
|
227
305
|
kind: "copy",
|
|
228
306
|
text: parseActionTemplate(parse, action.copy, name),
|
|
229
307
|
};
|
|
230
308
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
309
|
+
if ("open" in action) {
|
|
310
|
+
return {
|
|
311
|
+
kind: "open",
|
|
312
|
+
target: parseActionTemplate(parse, action.open, name),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return { kind: "reset", key: action.reset };
|
|
235
316
|
}
|
|
236
317
|
|
|
237
318
|
function parseActionTemplate(
|
|
@@ -292,7 +373,7 @@ export function linkFragment(
|
|
|
292
373
|
// renders the first display and clicks to the second member (an accordion
|
|
293
374
|
// sibling's path "counts as closed", a never-written toggle "counts as off").
|
|
294
375
|
function cycleIndex(
|
|
295
|
-
c: Extract<CompiledActionDecl, { kind: "set-cycle" }>,
|
|
376
|
+
c: Extract<CompiledActionDecl, { kind: "set-cycle" | "persist-cycle" }>,
|
|
296
377
|
store: VariableStore,
|
|
297
378
|
): number {
|
|
298
379
|
return Math.max(c.members.indexOf(readVar(store, c.stateVar)), 0);
|
|
@@ -400,6 +481,47 @@ function realize(
|
|
|
400
481
|
},
|
|
401
482
|
active: false,
|
|
402
483
|
};
|
|
484
|
+
// [LAW:one-source-of-truth] The persist-* arms mirror set-*'s realization
|
|
485
|
+
// verbatim (same literal/option/cycle/bounded semantics), only the wire
|
|
486
|
+
// verb differs (VERB_SET_CONFIG/VERB_STEP_CONFIG instead of
|
|
487
|
+
// VERB_SET_STATE/VERB_STEP_STATE) — the daemon-side handler is what makes
|
|
488
|
+
// the write durable, not the click itself.
|
|
489
|
+
case "persist-literal": {
|
|
490
|
+
const current = readVar(store, c.stateVar);
|
|
491
|
+
return {
|
|
492
|
+
effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, c.value] },
|
|
493
|
+
active: current === c.value,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
case "persist-option": {
|
|
497
|
+
const value = boundValue ?? display;
|
|
498
|
+
const current = readVar(store, c.stateVar);
|
|
499
|
+
return {
|
|
500
|
+
effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, value] },
|
|
501
|
+
active: current === value,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
case "persist-cycle": {
|
|
505
|
+
const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
|
|
506
|
+
return {
|
|
507
|
+
effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, next] },
|
|
508
|
+
active: false,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
case "persist-bounded": {
|
|
512
|
+
return {
|
|
513
|
+
effect: {
|
|
514
|
+
verb: VERB_STEP_CONFIG,
|
|
515
|
+
args: [sessionId, c.key, String(c.by)],
|
|
516
|
+
},
|
|
517
|
+
active: false,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
case "reset":
|
|
521
|
+
return {
|
|
522
|
+
effect: { verb: VERB_RESET_CONFIG, args: [sessionId, c.key] },
|
|
523
|
+
active: false,
|
|
524
|
+
};
|
|
403
525
|
}
|
|
404
526
|
}
|
|
405
527
|
|
|
@@ -419,7 +541,7 @@ function selectDisplay(
|
|
|
419
541
|
if (displays.length === 0) {
|
|
420
542
|
throw new Error(`action "${name}" needs a display (the clickable text)`);
|
|
421
543
|
}
|
|
422
|
-
if (action.kind === "set-cycle") {
|
|
544
|
+
if (action.kind === "set-cycle" || action.kind === "persist-cycle") {
|
|
423
545
|
if (displays.length !== 1 && displays.length !== action.members.length) {
|
|
424
546
|
throw new Error(
|
|
425
547
|
`action "${name}" cycles ${action.members.length} members; bind one display per member (${action.members.length}) or one static display, got ${displays.length}`,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The one record describing the segment a template is being evaluated for.
|
|
2
|
+
//
|
|
3
|
+
// [LAW:one-source-of-truth] Several template features need to know something
|
|
4
|
+
// about the enclosing segment — `{{ menu }}` derives its identity from the
|
|
5
|
+
// segment's name, `{{ color }}` must read the segment's own (transposed)
|
|
6
|
+
// palette, `{{ bgOf }}` must read the segment's own resolved background. Each
|
|
7
|
+
// of those could have carried its own published "current segment" pointer, and
|
|
8
|
+
// then two features could disagree about which segment is current. One record,
|
|
9
|
+
// one publisher, one clock.
|
|
10
|
+
//
|
|
11
|
+
// [LAW:no-ambient-temporal-coupling] This is *published state*, not ambient
|
|
12
|
+
// context: the render walk sets it before evaluating a segment's templates and
|
|
13
|
+
// clears it after, and nothing else writes it. The phase structure within a
|
|
14
|
+
// segment is likewise state rather than luck — `bg` is genuinely undefined
|
|
15
|
+
// while the `bg:` template is itself being evaluated, because at that moment
|
|
16
|
+
// the background is the thing being computed. Readers get a message naming the
|
|
17
|
+
// phase instead of a plausible-looking wrong color.
|
|
18
|
+
|
|
19
|
+
import type { ColorRgba, Palette } from "@promptctl/rich-js";
|
|
20
|
+
|
|
21
|
+
export interface ActiveSegment {
|
|
22
|
+
/** The segment's declared name — `{{ menu }}` derives its identity from it. */
|
|
23
|
+
readonly segName: string;
|
|
24
|
+
/**
|
|
25
|
+
* The palette this segment's colors resolve from: the base theme (session
|
|
26
|
+
* choice over config default, or an explicit per-segment `palette:` pin)
|
|
27
|
+
* after the render's look and this segment's hue shift.
|
|
28
|
+
*
|
|
29
|
+
* Template bodies read colors through THIS, not through a palette captured
|
|
30
|
+
* when the config was loaded — otherwise `{{ color "primary" }}` inside a
|
|
31
|
+
* segment paints from a different palette than the cell it sits in.
|
|
32
|
+
*/
|
|
33
|
+
readonly palette: Palette;
|
|
34
|
+
/**
|
|
35
|
+
* The segment's resolved background, once known.
|
|
36
|
+
*
|
|
37
|
+
* Undefined during evaluation of the segment's own `bg:` template — the
|
|
38
|
+
* ordering is bg, then fg, then body, and a background cannot be an input to
|
|
39
|
+
* computing itself.
|
|
40
|
+
*/
|
|
41
|
+
bg: ColorRgba | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The published pointer. Null between segments. */
|
|
45
|
+
export interface ActiveSegmentRef {
|
|
46
|
+
current: ActiveSegment | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createActiveSegmentRef(): ActiveSegmentRef {
|
|
50
|
+
return { current: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read the active segment, or fail with a message that says *why* nothing is
|
|
55
|
+
* active rather than what is missing.
|
|
56
|
+
*
|
|
57
|
+
* [LAW:no-defensive-null-guards] Null here is never a state to route around —
|
|
58
|
+
* it means a segment-scoped template function fired outside a segment render,
|
|
59
|
+
* which is either a wiring bug or an author using the function somewhere it
|
|
60
|
+
* cannot mean anything (a variable template, a node `when`). Both need to be
|
|
61
|
+
* seen, and in cc-candybar a thrown template error surfaces as a visible ⚠
|
|
62
|
+
* cell that `cc-candybar check` fails on. [LAW:no-silent-failure]
|
|
63
|
+
*/
|
|
64
|
+
export function requireActiveSegment(
|
|
65
|
+
ref: ActiveSegmentRef,
|
|
66
|
+
func: string,
|
|
67
|
+
): ActiveSegment {
|
|
68
|
+
const active = ref.current;
|
|
69
|
+
if (active === null) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`{{ ${func} }} is only available inside a segment's templates — ` +
|
|
72
|
+
`there is no active segment here. Segment-scoped functions cannot be ` +
|
|
73
|
+
`used in variable declarations or layout-node "when" predicates, ` +
|
|
74
|
+
`which are evaluated outside any segment.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return active;
|
|
78
|
+
}
|
package/src/render/menu.ts
CHANGED
|
@@ -50,25 +50,30 @@ import {
|
|
|
50
50
|
import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
|
|
51
51
|
import { linkFragment, readVar, type ActionRuntime } from "./action.js";
|
|
52
52
|
import { renderPicker } from "./picker.js";
|
|
53
|
+
import type { ActiveSegmentRef } from "./active-segment.js";
|
|
53
54
|
|
|
54
|
-
// [LAW:
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
55
|
+
// [LAW:one-type-per-behavior] A `{{ menu }}` needs one structural fact it cannot
|
|
56
|
+
// see about itself — the name of the segment it renders inside. That used to be
|
|
57
|
+
// its own `MenuPlacement` type; it is now a field on the ONE active-segment
|
|
58
|
+
// record the walk publishes (see render/active-segment.ts), because "which
|
|
59
|
+
// segment is rendering" is a single fact and a per-feature copy of it is a
|
|
60
|
+
// second clock. The menu reads `segName` and ignores the rest.
|
|
61
61
|
|
|
62
62
|
// [LAW:locality-or-seam] The runtime the `menu` func closes over. It shares the
|
|
63
63
|
// ACTION runtime (the menu's glyph and body resolve their actions/state from the
|
|
64
64
|
// same compiled table + store as every other helper) and READS the walk-published
|
|
65
|
-
//
|
|
66
|
-
// mutated only by the single owner (the render walk,
|
|
65
|
+
// active segment — both inputs, never written by the helper. The record is
|
|
66
|
+
// mutated only by the single owner (the render walk, around each segment eval) —
|
|
67
67
|
// the spatial cousin of the hue cursor, one mutator, never ambient.
|
|
68
68
|
// [LAW:no-ambient-temporal-coupling]
|
|
69
69
|
export interface MenuRuntime {
|
|
70
70
|
readonly action: ActionRuntime;
|
|
71
|
-
|
|
71
|
+
// [LAW:one-source-of-truth] The menu does not publish its own "which segment
|
|
72
|
+
// is current" pointer — it reads the ONE record the render walk publishes for
|
|
73
|
+
// every segment-scoped feature (the palette `{{ color }}` resolves against and
|
|
74
|
+
// the background `{{ bgOf }}` returns ride the same record). A second pointer
|
|
75
|
+
// would be a second clock for the same fact.
|
|
76
|
+
readonly activeSegment: ActiveSegmentRef;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
// [LAW:effects-at-boundaries] The body a `{{ menu }}` drops below its row rides as
|
|
@@ -95,7 +100,7 @@ function renderMenu(
|
|
|
95
100
|
options: MenuOptions,
|
|
96
101
|
runtime: MenuRuntime,
|
|
97
102
|
): RichText {
|
|
98
|
-
const placement = runtime.current;
|
|
103
|
+
const placement = runtime.activeSegment.current;
|
|
99
104
|
// [LAW:no-defensive-null-guards] The walk publishes a placement before every
|
|
100
105
|
// segment template evaluates; a `{{ menu }}` only renders inside a segment. A
|
|
101
106
|
// null here is a wiring bug (the func fired with no current segment), surfaced
|
package/src/render/picker.ts
CHANGED
|
@@ -28,7 +28,7 @@ import type { FuncMap } from "@promptctl/go-template-js";
|
|
|
28
28
|
import { toNumber } from "../var-system/types.js";
|
|
29
29
|
import { stripChromeCols } from "./strip.js";
|
|
30
30
|
import { TERM_COLS_VAR } from "../config/dsl-types.js";
|
|
31
|
-
import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
|
|
31
|
+
import { effectsUrl, VERB_SET_CONFIG, VERB_SET_STATE } from "../click/wire.js";
|
|
32
32
|
import {
|
|
33
33
|
linkFragment,
|
|
34
34
|
readVar,
|
|
@@ -136,6 +136,31 @@ function requireKind<K extends CompiledActionDecl["kind"]>(
|
|
|
136
136
|
return action as Extract<CompiledActionDecl, { kind: K }>;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// [LAW:one-source-of-truth] The apply action a picker grid binds to is EITHER
|
|
140
|
+
// of set-option's two durability twins (src/render/action.ts's persist-*
|
|
141
|
+
// mirrors set-*'s shapes one for one) — a picker over `persist("charset",
|
|
142
|
+
// from:"charsets")` is exactly as legal as one over `set("theme",
|
|
143
|
+
// from:"themes")`, differing only in which wire verb the option click emits
|
|
144
|
+
// (VERB_SET_CONFIG vs VERB_SET_STATE), never in shape (both carry key,
|
|
145
|
+
// stateVar, options). Rejecting persist-option here would be an artificial
|
|
146
|
+
// gap: the same option-domain gate (deriveActionValidators) covers both kinds
|
|
147
|
+
// identically, so there is nothing about "picker" that's set-only.
|
|
148
|
+
function requireOptionKind(
|
|
149
|
+
runtime: ActionRuntime,
|
|
150
|
+
name: string,
|
|
151
|
+
): Extract<CompiledActionDecl, { kind: "set-option" | "persist-option" }> {
|
|
152
|
+
const action = runtime.compiled.get(name);
|
|
153
|
+
if (
|
|
154
|
+
!action ||
|
|
155
|
+
(action.kind !== "set-option" && action.kind !== "persist-option")
|
|
156
|
+
) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`picker references action "${name}" which must be a set-option or persist-option action ({ set, from } or { persist, from }), got ${action ? `a ${action.kind} action` : "no such action"}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return action;
|
|
162
|
+
}
|
|
163
|
+
|
|
139
164
|
// [LAW:dataflow-not-control-flow] The page value (and the live width) select
|
|
140
165
|
// which option cells render and which boundary arrows exist — a boundary arrow is
|
|
141
166
|
// an ABSENT fragment, never a skipped branch. ←/→ navigate the page key
|
|
@@ -155,12 +180,7 @@ export function renderPicker(
|
|
|
155
180
|
paged: boolean,
|
|
156
181
|
runtime: ActionRuntime,
|
|
157
182
|
): RichText {
|
|
158
|
-
const apply =
|
|
159
|
-
runtime,
|
|
160
|
-
applyName,
|
|
161
|
-
"set-option",
|
|
162
|
-
"a set-option action ({ set, from })",
|
|
163
|
-
);
|
|
183
|
+
const apply = requireOptionKind(runtime, applyName);
|
|
164
184
|
const store = runtime.store;
|
|
165
185
|
const sessionId = readVar(store, "session.id");
|
|
166
186
|
const current = readVar(store, apply.stateVar);
|
|
@@ -222,13 +242,31 @@ export function renderPicker(
|
|
|
222
242
|
const closeUrl = effectsUrl([
|
|
223
243
|
{ verb: VERB_SET_STATE, args: [sessionId, ...closeFlat] },
|
|
224
244
|
]);
|
|
245
|
+
// [LAW:one-source-of-truth] A set-option apply folds its closeOnPick pairs
|
|
246
|
+
// into ONE set-state batch (setState is variadic — see daemon/verbs). A
|
|
247
|
+
// persist-option apply cannot: setConfig takes exactly one (key, value), so
|
|
248
|
+
// its close pairs (always SessionState — open/page live there regardless of
|
|
249
|
+
// the apply's durability) ride as a SECOND effect in the same dispatch,
|
|
250
|
+
// still one atomic click via effectsUrl's array.
|
|
225
251
|
const optionUrl = (option: string): string =>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
252
|
+
apply.kind === "persist-option"
|
|
253
|
+
? effectsUrl([
|
|
254
|
+
{ verb: VERB_SET_CONFIG, args: [sessionId, apply.key, option] },
|
|
255
|
+
...(closeOnPick
|
|
256
|
+
? [{ verb: VERB_SET_STATE, args: [sessionId, ...closeFlat] }]
|
|
257
|
+
: []),
|
|
258
|
+
])
|
|
259
|
+
: effectsUrl([
|
|
260
|
+
{
|
|
261
|
+
verb: VERB_SET_STATE,
|
|
262
|
+
args: [
|
|
263
|
+
sessionId,
|
|
264
|
+
apply.key,
|
|
265
|
+
option,
|
|
266
|
+
...(closeOnPick ? closeFlat : []),
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
]);
|
|
232
270
|
|
|
233
271
|
const frags: RichText[] = [linkFragment(PICKER_CLOSE, closeUrl, false)];
|
|
234
272
|
if (pageIdx > 0) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Segment-scoped color functions: the seam between rich-js's palette-free
|
|
2
|
+
// color vocabulary and cc-candybar's notion of a segment.
|
|
3
|
+
//
|
|
4
|
+
// rich-js owns every color operation and knows nothing about segments;
|
|
5
|
+
// cc-candybar owns segments and performs no color arithmetic of its own
|
|
6
|
+
// [LAW:rich-js-owns-color-math]. This module is exactly the join: it supplies
|
|
7
|
+
// rich-js's `color` with *which* palette, and adds the one function whose
|
|
8
|
+
// meaning is candybar-specific — `bgOf`, the background of the segment
|
|
9
|
+
// currently rendering. [LAW:one-way-deps]
|
|
10
|
+
|
|
11
|
+
import type { FuncMap, TemplateFunc } from "@promptctl/go-template-js";
|
|
12
|
+
import { paletteFuncs } from "@promptctl/rich-js/template-bindings";
|
|
13
|
+
import {
|
|
14
|
+
requireActiveSegment,
|
|
15
|
+
type ActiveSegmentRef,
|
|
16
|
+
} from "./active-segment.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Bind `color` and `bgOf` to the segment the walk has published.
|
|
20
|
+
*
|
|
21
|
+
* **Why `color` reads a live palette.** A segment's rendered palette is not a
|
|
22
|
+
* property of the loaded config — it is the base theme (session choice over
|
|
23
|
+
* config default) adapted by the render's look and the segment's hue shift,
|
|
24
|
+
* all resolved per render, per segment. Binding `color` to a palette captured
|
|
25
|
+
* when the config loaded put the *body* of a template on a different palette
|
|
26
|
+
* than the `bg:`/`fg:` of the very same segment, so `{{ color "primary" }}`
|
|
27
|
+
* and `bg: "primary"` could name one thing and paint two.
|
|
28
|
+
* [LAW:one-source-of-truth]
|
|
29
|
+
*
|
|
30
|
+
* That divergence was not exotic. Any session theme click moved the segment's
|
|
31
|
+
* background while leaving every in-body semantic color where it was; a look
|
|
32
|
+
* or a per-segment hue rotation did the same. Reading the live palette makes
|
|
33
|
+
* the two agree by construction rather than by coincidence.
|
|
34
|
+
*
|
|
35
|
+
* **Why `bgOf` exists.** De-emphasis — drawing labels, punctuation and ids
|
|
36
|
+
* quieter than the facts they frame — is "move this color toward the
|
|
37
|
+
* background." A palette's own `foreground-muted` blends toward the *theme's*
|
|
38
|
+
* background, which is the wrong target for any segment not painted in it: a
|
|
39
|
+
* segment on `surface-active` needs its muted text blended toward
|
|
40
|
+
* `surface-active`. Only the segment knows its own background, so only the
|
|
41
|
+
* segment can supply it:
|
|
42
|
+
*
|
|
43
|
+
* ```
|
|
44
|
+
* {{ $muted := mix (color "foreground") (bgOf) 65 }}
|
|
45
|
+
* {{ fg $muted .git.repoName }} {{ fg (color "primary") .git.branch }}
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* This is also what makes contrast reachable: `{{ fg (contrastOn (bgOf)) … }}`
|
|
49
|
+
* asks a question about a real background, where the old spec-grammar `"auto"`
|
|
50
|
+
* could only ever be handed a hardcoded literal.
|
|
51
|
+
*/
|
|
52
|
+
export function segmentColorFuncs(ref: ActiveSegmentRef): FuncMap {
|
|
53
|
+
const bgOf: TemplateFunc = {
|
|
54
|
+
fn: (() => {
|
|
55
|
+
const active = requireActiveSegment(ref, "bgOf");
|
|
56
|
+
if (active.bg === undefined) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`{{ bgOf }} is not available while segments.${active.segName}'s own ` +
|
|
59
|
+
`"bg:" is being evaluated — the background is what that template ` +
|
|
60
|
+
`computes. Reach for a palette color there instead, e.g. ` +
|
|
61
|
+
`bg: '{{ darken (color "surface") 1 }}'.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return active.bg.hex;
|
|
65
|
+
}) as TemplateFunc["fn"],
|
|
66
|
+
argTypes: [],
|
|
67
|
+
returnType: "string",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
...paletteFuncs(() => requireActiveSegment(ref, "color").palette),
|
|
72
|
+
bgOf,
|
|
73
|
+
};
|
|
74
|
+
}
|