@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.
Files changed (44) hide show
  1. package/dist/index.mjs +86 -85
  2. package/package.json +6 -6
  3. package/schema/cc-candybar.schema.json +193 -4
  4. package/src/check.ts +49 -27
  5. package/src/click/wire.ts +16 -0
  6. package/src/config/action.ts +57 -22
  7. package/src/config/default-dsl-config.ts +424 -55
  8. package/src/config/dsl-loader.ts +14 -2
  9. package/src/config/dsl-types.ts +59 -0
  10. package/src/config/loader/actions.ts +283 -109
  11. package/src/config/loader/cross-ref.ts +148 -28
  12. package/src/config/loader/emit-schema.ts +2 -0
  13. package/src/config/loader/globals.ts +118 -31
  14. package/src/config/loader/merge.ts +58 -1
  15. package/src/config/loader/persist-target.ts +32 -0
  16. package/src/config/loader/presets.ts +107 -0
  17. package/src/config/option-domain.ts +164 -0
  18. package/src/config/presets.ts +188 -0
  19. package/src/daemon/cache/git.ts +1 -1
  20. package/src/daemon/cache/render.ts +72 -7
  21. package/src/daemon/config-overrides-store.ts +322 -0
  22. package/src/daemon/paths.ts +10 -0
  23. package/src/daemon/render-payload.ts +84 -19
  24. package/src/daemon/server.ts +68 -55
  25. package/src/daemon/verbs/config-validators.ts +127 -0
  26. package/src/daemon/verbs/index.ts +129 -2
  27. package/src/daemon/verbs/state-validators.ts +98 -586
  28. package/src/daemon/verbs/validator-registry.ts +457 -0
  29. package/src/demo/dsl.ts +17 -10
  30. package/src/dsl/node-registry.ts +54 -39
  31. package/src/dsl/render.ts +158 -46
  32. package/src/help-text.ts +3 -3
  33. package/src/install/index.ts +2 -2
  34. package/src/render/action.ts +155 -33
  35. package/src/render/active-segment.ts +78 -0
  36. package/src/render/menu.ts +16 -11
  37. package/src/render/picker.ts +51 -13
  38. package/src/render/segment-color.ts +74 -0
  39. package/src/segments/git.ts +389 -48
  40. package/src/template-engine/colors.ts +67 -45
  41. package/src/template-engine/engine.ts +11 -12
  42. package/src/themes/index.ts +1 -4
  43. package/src/themes/palette-resolvers.ts +22 -30
  44. package/src/themes/policy.ts +37 -16
@@ -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
+ }
@@ -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:types-are-the-program] One menu placement: the structural fact a context-
55
- // free `{{ menu }}` cannot see about itself — the name of the segment it renders
56
- // inside. Published by the walk per segment render; the helper reads the live
57
- // value to derive identity.
58
- export interface MenuPlacement {
59
- readonly segName: string;
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
- // current placement — both inputs, never written by the helper. `current` is
66
- // mutated only by the single owner (the render walk, before each segment eval) —
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
- current: MenuPlacement | null;
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
@@ -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 = requireKind(
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
- effectsUrl([
227
- {
228
- verb: VERB_SET_STATE,
229
- args: [sessionId, apply.key, option, ...(closeOnPick ? closeFlat : [])],
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
+ }