@promptctl/cc-candybar 1.31.0 → 1.32.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.
@@ -396,10 +396,14 @@ function checkPresetRootOpsTarget(
396
396
  if (discriminator === "reset") return;
397
397
  const hasRemove = "removeSegment" in a;
398
398
  const hasInsert = "insertSegment" in a;
399
- if (!hasRemove && !hasInsert) {
399
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
400
+ // sibling — the segment name is picked at render, so only `anchor` (still
401
+ // literal at author time) needs the declared-segment check below.
402
+ const hasInsertFrom = "insertSegmentFrom" in a;
403
+ if (!hasRemove && !hasInsert && !hasInsertFrom) {
400
404
  ctx.issues.push({
401
405
  path: at,
402
- message: `actions.${name}: "${key}" is a "presets.<name>.rootOps" target and can only be paired with "removeSegment" or "insertSegment" (not "to"/"from"/"cycle"/bounded — those have no meaning as a tree op)`,
406
+ message: `actions.${name}: "${key}" is a "presets.<name>.rootOps" target and can only be paired with "removeSegment", "insertSegment", or "insertSegmentFrom" (not "to"/"from"/"cycle"/bounded — those have no meaning as a tree op)`,
403
407
  line,
404
408
  });
405
409
  return;
@@ -429,6 +433,13 @@ function checkPresetRootOpsTarget(
429
433
  });
430
434
  }
431
435
  }
436
+ if (hasInsertFrom && "insertSegmentFrom" in a && missing(a.anchor)) {
437
+ ctx.issues.push({
438
+ path: at,
439
+ message: `actions.${name}: anchor "${a.anchor}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
440
+ line,
441
+ });
442
+ }
432
443
  }
433
444
 
434
445
  function hasStateKind(cfg: DslConfig): boolean {
@@ -0,0 +1,111 @@
1
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's TOGGLE half of edit
2
+ // mode — the disclosure primitive's third body-kind, one register down from
3
+ // group sugar and `{{ menu }}`: where those two synthesize a whole trigger +
4
+ // body, edit mode synthesizes only the on/off state + toggle ACTION here
5
+ // (`edit.mode` / `edit.toggle`), so a hand-authored `{{ action "edit.toggle"
6
+ // "✎" }}` cross-ref-checks and compiles exactly like any other action — no
7
+ // bespoke "this action always exists" carve-out anywhere downstream. The
8
+ // per-segment +/- CHROME is a separate, LATER pass
9
+ // (src/config/edit-chrome.ts) that runs on the fully merged, preset-resolved,
10
+ // rootOps-replayed config (inside validateConfig, not here) because it needs
11
+ // data — which segments are in which preset's CURRENT tree — that does not
12
+ // exist yet at this per-file parse stage. Splitting the two halves across two
13
+ // synthesis points is not incidental: the toggle is authorable/cross-ref-able
14
+ // content (like a group's name or a menu's apply action), the chrome is
15
+ // derived data (like a group's lowered body), and each belongs at the stage
16
+ // that has what it needs.
17
+ //
18
+ // [LAW:carrying-cost] DEMAND-DRIVEN, not unconditional — this is the one place
19
+ // this pass diverges from group/menu synthesis's OWN precedent of "reserve
20
+ // unconditionally, synthesize on demand" and leans fully into the "on demand"
21
+ // half: a config that references `{{ action "edit.toggle" … }}` nowhere gets
22
+ // NEITHER the toggle var/action NOR (edit-chrome.ts checks for the SAME
23
+ // action's presence) any per-segment chrome. This matters concretely, not just
24
+ // as a purity concern — `edit.mode` is a `state` variable and `edit.toggle` is
25
+ // a `set` action, and cross-ref.ts requires a global `session.id` variable the
26
+ // instant ANY state var or set action exists anywhere in a config. Synthesizing
27
+ // either unconditionally would force session.id onto every purely-static,
28
+ // non-interactive bar in the corpus — exactly the regression an early version
29
+ // of this pass caused. The reserved namespace stays reserved unconditionally
30
+ // (mirroring reservedNamespaceCollisions' own contract); only the SYNTHESIS is
31
+ // conditional.
32
+
33
+ import { createEngine } from "@promptctl/go-template-js";
34
+ import type { Mutable, ValidateCtx } from "./validate-core.js";
35
+ import type { RawDslConfig, VariableDecl } from "../dsl-types.js";
36
+ import type { ActionDecl } from "../action.js";
37
+ import {
38
+ DISCLOSURE_CLOSED,
39
+ disclosureCycleAction,
40
+ disclosureStateVar,
41
+ } from "../disclosure.js";
42
+ import { reservedNamespaceCollisions } from "./reserved-namespace.js";
43
+
44
+ // [LAW:one-source-of-truth] The reserved namespace every edit-mode artifact —
45
+ // this toggle AND edit-chrome.ts's per-position +/- actions/segments — lives
46
+ // under, mirroring `groups.`/`menus.`. Exported so edit-chrome.ts's LATER
47
+ // synthesis (and its `isChromeExempt` exclusion of edit-mode's own chrome
48
+ // from being treated as ordinary, removable/addable content) reads the same
49
+ // string, never a second copy.
50
+ export const EDIT_NS = "edit.";
51
+
52
+ // [LAW:single-enforcer] The SessionState key edit mode's on/off state lives
53
+ // at, and the toggle action's identity member. Both edit-chrome.ts (every
54
+ // synthesized affordance's `when` gate) and a hand-authored trigger segment
55
+ // read/write these same two names — one declaration, no drift.
56
+ export const EDIT_MODE_KEY = "edit.mode";
57
+ export const EDIT_TOGGLE_ACTION = "edit.toggle";
58
+ export const EDIT_MODE_OPEN = "open";
59
+
60
+ // [LAW:one-source-of-truth] The predicate every synthesized +/- chrome
61
+ // segment gates on — spelled once here so edit-chrome.ts never hand-rolls
62
+ // the template string a second time.
63
+ export const EDIT_MODE_GATE = `{{ eq .${EDIT_MODE_KEY} "${EDIT_MODE_OPEN}" }}`;
64
+
65
+ // [LAW:single-enforcer] The ONE detector for "does this file want edit mode":
66
+ // a literal `{{ action "edit.toggle" … }}` call somewhere a segment's
67
+ // template/bg/fg can reach — the SAME AST-based approach
68
+ // menu-synth.ts's segmentReferencesMenu uses (robust against whitespace,
69
+ // pipelines, and lookalike text a source-string scan would false-positive
70
+ // or false-negative on), one function name over. A bare engine purely for
71
+ // introspection: it never evaluates, so a malformed template simply yields
72
+ // no match here (registerDslConfig re-parses and reports the real error;
73
+ // [LAW:no-silent-failure] this pass just isn't the one that reports it).
74
+ function referencesEditToggle(template: string): boolean {
75
+ const engine = createEngine<string>({ fromString: (s) => s });
76
+ try {
77
+ return engine
78
+ .parse(template)
79
+ .referencedCalls()
80
+ .some((c) => c.name === "action" && c.args[0] === EDIT_TOGGLE_ACTION);
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function fileWantsEditMode(out: Readonly<RawDslConfig>): boolean {
87
+ for (const seg of Object.values(out.segments ?? {})) {
88
+ for (const field of [seg.template, seg.bg, seg.fg] as const) {
89
+ if (typeof field === "string" && referencesEditToggle(field)) {
90
+ return true;
91
+ }
92
+ }
93
+ }
94
+ return false;
95
+ }
96
+
97
+ export function synthesizeEditModeToggle(
98
+ ctx: ValidateCtx,
99
+ out: Mutable<RawDslConfig>,
100
+ ): void {
101
+ reservedNamespaceCollisions(ctx, out, EDIT_NS, "edit mode");
102
+ if (!fileWantsEditMode(out)) return;
103
+ const variables: Record<string, VariableDecl> = {
104
+ [EDIT_MODE_KEY]: disclosureStateVar(EDIT_MODE_KEY, DISCLOSURE_CLOSED),
105
+ };
106
+ const actions: Record<string, ActionDecl> = {
107
+ [EDIT_TOGGLE_ACTION]: disclosureCycleAction(EDIT_MODE_KEY, EDIT_MODE_OPEN),
108
+ };
109
+ out.variables = { ...(out.variables ?? {}), ...variables };
110
+ out.actions = { ...(out.actions ?? {}), ...actions };
111
+ }
@@ -15,6 +15,7 @@ import {
15
15
  perConfigDomainsFor,
16
16
  resolveOptionDomain,
17
17
  } from "../../config/option-domain";
18
+ import { addableSegmentDomains } from "../../config/edit-chrome";
18
19
  import type { DslConfig } from "../../config/dsl-types";
19
20
  import { isGlobalsField } from "../config-overrides-store";
20
21
  import { encodeLayoutOp } from "../../config/layout-ops";
@@ -111,6 +112,34 @@ function actionKeySpecs(
111
112
  },
112
113
  ];
113
114
  }
115
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
116
+ // sibling: the allow-list is the ENCODED op token for every domain member,
117
+ // not the raw member — mirroring how a literal `insertSegment` contributes
118
+ // its own single encoded token above. A click carrying an option this
119
+ // domain never named — or naming a real segment but the wrong anchor/
120
+ // relation — cannot decode to a member of this list, so it is rejected the
121
+ // same loud way an unknown literal op token already is.
122
+ if ("insertSegmentFrom" in a) {
123
+ return [
124
+ {
125
+ key: a.persist,
126
+ spec: {
127
+ kind: "allow-list",
128
+ allowed: resolveOptionDomain(
129
+ a.insertSegmentFrom,
130
+ perConfigDomains,
131
+ ).map((segment) =>
132
+ encodeLayoutOp({
133
+ op: "insert",
134
+ segment,
135
+ anchor: a.anchor,
136
+ relation: a.relation,
137
+ }),
138
+ ),
139
+ },
140
+ },
141
+ ];
142
+ }
114
143
  return [
115
144
  {
116
145
  key: a.persist,
@@ -141,7 +170,16 @@ function configKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
141
170
 
142
171
  function actionContributions(config: DslConfig): KeySpecContribution[] {
143
172
  const seeds = configKeySeeds(config);
144
- const perConfigDomains = perConfigDomainsFor(config);
173
+ // [LAW:one-source-of-truth] The "addable segment" domains
174
+ // (edit-chrome.ts's `addableSegmentDomains`) merge in here alongside
175
+ // looks/presets — the same per-preset seam `insertSegmentFrom` resolves
176
+ // through at render (render.ts's registerDslConfig merges the identical
177
+ // map), so the rendered picker options and the derived click gate can
178
+ // never diverge over what's addable.
179
+ const perConfigDomains = new Map([
180
+ ...perConfigDomainsFor(config),
181
+ ...addableSegmentDomains(config),
182
+ ]);
145
183
  return Object.values(config.actions).flatMap((a) =>
146
184
  actionKeySpecs(a, seeds, perConfigDomains),
147
185
  );
package/src/dsl/render.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  import { HUE_STEP_VAR } from "../config/dsl-types.js";
24
24
  import { perConfigDomainsFor } from "../config/option-domain.js";
25
25
  import { PRESET_FLOOR, presetNames, presetRoot } from "../config/presets.js";
26
+ import { addableSegmentDomains } from "../config/edit-chrome.js";
26
27
  import type { VariableStore } from "../var-system/store.js";
27
28
  import type { SourceRegistry } from "../var-system/sources.js";
28
29
  import {
@@ -340,7 +341,15 @@ export function registerDslConfig(
340
341
  // one source.
341
342
  const lookNames = Object.keys(config.looks);
342
343
  const presetOptions = presetNames(config.presets);
343
- const perConfigDomains = perConfigDomainsFor(config);
344
+ // [LAW:one-source-of-truth] The "addable segment" per-preset domains merge
345
+ // in here — the SAME map config-validators.ts's deriveConfigActionValidators
346
+ // merges — so a synthesized `insertSegmentFrom` action's rendered options
347
+ // and its derived click gate resolve from one source, never two
348
+ // independently-computed sets.
349
+ const perConfigDomains = new Map([
350
+ ...perConfigDomainsFor(config),
351
+ ...addableSegmentDomains(config),
352
+ ]);
344
353
  const engine = createCcCandybarEngine(
345
354
  {
346
355
  ...actionFuncs(actionRuntime),
@@ -145,6 +145,22 @@ export type CompiledActionDecl =
145
145
  // template-bound option, unlike persist-option), so `op` is precomputed
146
146
  // here rather than reconstructed from raw fields at every realize() call.
147
147
  | { readonly kind: "layout-op"; readonly key: string; readonly op: LayoutOp }
148
+ // [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
149
+ // sibling of layout-op: `anchor`/`relation` are fixed at compile time (the
150
+ // POSITION is author-time data) but the segment name comes from the
151
+ // template's bound option — the option-picking shape `persist-option`
152
+ // already has, minus the value being written VERBATIM. `requireOptionKind`
153
+ // (render/picker.ts) admits this kind alongside set-option/persist-option
154
+ // so a `{{ menu }}`/`{{ picker }}` can drive it with zero picker changes;
155
+ // only the WRITE (realize(), below) differs — it encodes the picked option
156
+ // into a LayoutOp instead of persisting it as-is.
157
+ | {
158
+ readonly kind: "layout-op-option";
159
+ readonly key: string;
160
+ readonly anchor: string;
161
+ readonly relation: "before" | "after";
162
+ readonly options: readonly string[];
163
+ }
148
164
  // [LAW:one-source-of-truth] brandon-layout-edit-2gc.2's global history
149
165
  // step over the overrides layer — `reset`'s fine-grained sibling. No key:
150
166
  // there is nothing to carry, since the history stack (not this action) is
@@ -328,6 +344,17 @@ function compileAction(
328
344
  },
329
345
  };
330
346
  }
347
+ if ("insertSegmentFrom" in action) {
348
+ return {
349
+ kind: "layout-op-option",
350
+ key: action.persist,
351
+ anchor: action.anchor,
352
+ relation: action.relation,
353
+ options: [
354
+ ...resolveOptionDomain(action.insertSegmentFrom, perConfigDomains),
355
+ ],
356
+ };
357
+ }
331
358
  return {
332
359
  kind: "persist-bounded",
333
360
  key: action.persist,
@@ -587,6 +614,28 @@ function realize(
587
614
  },
588
615
  active: false,
589
616
  };
617
+ // [LAW:one-source-of-truth] The picked option (boundValue ?? display — the
618
+ // SAME resolution persist-option uses) becomes the op's `segment`; anchor/
619
+ // relation are the compiled literals. Same wire shape a literal layout-op
620
+ // emits, so the daemon's apply-layout-op handler and undo/redo need no
621
+ // knowledge of where the segment name came from. Never "active": a
622
+ // structural edit is a one-shot trigger, not a current-selection toggle.
623
+ case "layout-op-option": {
624
+ const segment = boundValue ?? display;
625
+ const op: LayoutOp = {
626
+ op: "insert",
627
+ segment,
628
+ anchor: c.anchor,
629
+ relation: c.relation,
630
+ };
631
+ return {
632
+ effect: {
633
+ verb: VERB_APPLY_LAYOUT_OP,
634
+ args: [sessionId, c.key, encodeLayoutOp(op)],
635
+ },
636
+ active: false,
637
+ };
638
+ }
590
639
  }
591
640
  }
592
641
 
@@ -28,7 +28,13 @@ 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_CONFIG, VERB_SET_STATE } from "../click/wire.js";
31
+ import {
32
+ effectsUrl,
33
+ VERB_APPLY_LAYOUT_OP,
34
+ VERB_SET_CONFIG,
35
+ VERB_SET_STATE,
36
+ } from "../click/wire.js";
37
+ import { encodeLayoutOp } from "../config/layout-ops.js";
32
38
  import {
33
39
  linkFragment,
34
40
  readVar,
@@ -136,26 +142,33 @@ function requireKind<K extends CompiledActionDecl["kind"]>(
136
142
  return action as Extract<CompiledActionDecl, { kind: K }>;
137
143
  }
138
144
 
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.
145
+ // [LAW:one-source-of-truth] The apply action a picker grid binds to is one of
146
+ // THREE option-domain-driven kinds (src/render/action.ts): set-option/
147
+ // persist-option (a picked value is WRITTEN VERBATIM set-option's two
148
+ // durability twins, differing only in wire verb VERB_SET_CONFIG vs
149
+ // VERB_SET_STATE) or layout-op-option (a picked value is ENCODED into a
150
+ // structural LayoutOp before writing brandon-layout-edit-2gc.3's
151
+ // `insertSegmentFrom`). All three share the same option-domain gate
152
+ // (deriveActionValidators/deriveConfigActionValidators) and the same "pick a
153
+ // cell, apply it" shape; only WHAT the click writes differs, which is
154
+ // realize()'s job, not the picker's. Rejecting any of the three here would be
155
+ // an artificial gap — there is nothing about "picker" that excludes one kind.
148
156
  function requireOptionKind(
149
157
  runtime: ActionRuntime,
150
158
  name: string,
151
- ): Extract<CompiledActionDecl, { kind: "set-option" | "persist-option" }> {
159
+ ): Extract<
160
+ CompiledActionDecl,
161
+ { kind: "set-option" | "persist-option" | "layout-op-option" }
162
+ > {
152
163
  const action = runtime.compiled.get(name);
153
164
  if (
154
165
  !action ||
155
- (action.kind !== "set-option" && action.kind !== "persist-option")
166
+ (action.kind !== "set-option" &&
167
+ action.kind !== "persist-option" &&
168
+ action.kind !== "layout-op-option")
156
169
  ) {
157
170
  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"}`,
171
+ `picker references action "${name}" which must be a set-option, persist-option, or layout-op-option action ({ set, from }, { persist, from }, or { persist, insertSegmentFrom, anchor, relation }), got ${action ? `a ${action.kind} action` : "no such action"}`,
159
172
  );
160
173
  }
161
174
  return action;
@@ -183,7 +196,13 @@ export function renderPicker(
183
196
  const apply = requireOptionKind(runtime, applyName);
184
197
  const store = runtime.store;
185
198
  const sessionId = readVar(store, "session.id");
186
- const current = readVar(store, apply.stateVar);
199
+ // [LAW:no-defensive-null-guards] layout-op-option carries no `stateVar` —
200
+ // a structural insert is a one-shot trigger, not a persisted single value,
201
+ // so there is no "current selection" to mark. `undefined` here (never a
202
+ // magic sentinel string) makes every option's `option === current` compare
203
+ // false below, structurally rather than by accident.
204
+ const current =
205
+ "stateVar" in apply ? readVar(store, apply.stateVar) : undefined;
187
206
  const widths = apply.options.map(cellWidth);
188
207
 
189
208
  // ✕ is always present; ←/→ appear only on a multi-page menu. Reserve arrow
@@ -243,30 +262,44 @@ export function renderPicker(
243
262
  { verb: VERB_SET_STATE, args: [sessionId, ...closeFlat] },
244
263
  ]);
245
264
  // [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.
251
- const optionUrl = (option: string): string =>
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
- ]);
265
+ // into ONE set-state batch (setState is variadic — see daemon/verbs).
266
+ // persist-option and layout-op-option cannot: setConfig/apply-layout-op
267
+ // each take exactly one (key, value) pair, so their close pairs (always
268
+ // SessionState open/page live there regardless of the apply's
269
+ // durability) ride as a SECOND effect in the same dispatch, still one
270
+ // atomic click via effectsUrl's array. layout-op-option's "value" is the
271
+ // ENCODED op (segment=option, anchor/relation from the compiled action),
272
+ // not the option verbatim — the one place this kind's write differs from
273
+ // persist-option's.
274
+ const closeEffect = closeOnPick
275
+ ? [{ verb: VERB_SET_STATE, args: [sessionId, ...closeFlat] }]
276
+ : [];
277
+ const optionUrl = (option: string): string => {
278
+ if (apply.kind === "persist-option") {
279
+ return effectsUrl([
280
+ { verb: VERB_SET_CONFIG, args: [sessionId, apply.key, option] },
281
+ ...closeEffect,
282
+ ]);
283
+ }
284
+ if (apply.kind === "layout-op-option") {
285
+ const op = encodeLayoutOp({
286
+ op: "insert",
287
+ segment: option,
288
+ anchor: apply.anchor,
289
+ relation: apply.relation,
290
+ });
291
+ return effectsUrl([
292
+ { verb: VERB_APPLY_LAYOUT_OP, args: [sessionId, apply.key, op] },
293
+ ...closeEffect,
294
+ ]);
295
+ }
296
+ return effectsUrl([
297
+ {
298
+ verb: VERB_SET_STATE,
299
+ args: [sessionId, apply.key, option, ...(closeOnPick ? closeFlat : [])],
300
+ },
301
+ ]);
302
+ };
270
303
 
271
304
  const frags: RichText[] = [linkFragment(PICKER_CLOSE, closeUrl, false)];
272
305
  if (pageIdx > 0) {