@promptctl/cc-candybar 1.30.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.
@@ -145,6 +145,8 @@ const ACTION_ARMS: Record<ActionKey, ArmParse<ActionDecl>> = {
145
145
  copy: templateArm("copy"),
146
146
  open: templateArm("open"),
147
147
  reset: resetArm,
148
+ undo: markerArm("undo"),
149
+ redo: markerArm("redo"),
148
150
  };
149
151
 
150
152
  // [LAW:one-source-of-truth] A copy/open action emits the closed single-key
@@ -170,6 +172,8 @@ function actionDeclJson(): JsonNode {
170
172
  templateArmJson("copy"),
171
173
  templateArmJson("open"),
172
174
  templateArmJson("reset"),
175
+ markerArmJson("undo"),
176
+ markerArmJson("redo"),
173
177
  ],
174
178
  };
175
179
  }
@@ -228,6 +232,47 @@ function resetArm(
228
232
  return key === null ? null : { reset: key };
229
233
  }
230
234
 
235
+ // [LAW:one-type-per-behavior] `undo`/`redo` are copy/open/reset's shape one
236
+ // step further reduced: a single required key whose only legal VALUE is the
237
+ // literal `true` (mirrors intMarkerSpec — a marker, not data), because there
238
+ // is no key to name: the history they step is one global stack over the
239
+ // whole overrides layer, not a per-target write. `function`, not a const
240
+ // arrow, so ACTION_ARMS above (built before this declaration in source
241
+ // order) can reference it directly via hoisting.
242
+ function markerArm(key: "undo" | "redo"): ArmParse<ActionDecl> {
243
+ return (ctx, path, raw) => {
244
+ for (const k of Object.keys(raw)) {
245
+ if (k !== key)
246
+ issue(
247
+ ctx,
248
+ `${path}.${k}`,
249
+ `Unknown key "${k}" on a ${key} action. Expected only: ${key}`,
250
+ );
251
+ }
252
+ if (raw[key] !== true) {
253
+ issue(
254
+ ctx,
255
+ `${path}.${key}`,
256
+ `${key} must be the literal true (it takes no key — it steps the ONE global history over the whole overrides layer), got ${describeValue(raw[key])}`,
257
+ );
258
+ return null;
259
+ }
260
+ return { [key]: true } as unknown as ActionDecl;
261
+ };
262
+ }
263
+
264
+ // [LAW:one-source-of-truth] Mirrors templateArmJson's shape one level
265
+ // narrower: the value schema is `const: true`, not `type: string` — a
266
+ // marker action carries no data, on the wire or in the schema.
267
+ function markerArmJson(key: "undo" | "redo"): JsonNode {
268
+ return {
269
+ type: "object",
270
+ properties: { [key]: { const: true } },
271
+ required: [key],
272
+ additionalProperties: false,
273
+ };
274
+ }
275
+
231
276
  // ─── The `set` value-source sub-union ────────────────────────────────────────
232
277
 
233
278
  // [LAW:single-enforcer] A set-state URL path segment must be a non-empty,
@@ -343,6 +388,20 @@ const INSERT_SEGMENT_FIELDS: FieldSpecMap<{
343
388
  anchor: layoutNameSpec("anchor"),
344
389
  relation: relationSpec(),
345
390
  };
391
+ // [LAW:one-type-per-behavior] `insertSegmentFrom`'s payload mirrors
392
+ // `insertSegment`'s verbatim except the segment name is a `from`-shaped
393
+ // OptionDomain (fromSpec, the SAME field `set`/`persist … from` already
394
+ // validate) instead of a literal layout name — the "to" vs "from" split every
395
+ // other value source already draws, one arm over.
396
+ const INSERT_SEGMENT_FROM_FIELDS: FieldSpecMap<{
397
+ insertSegmentFrom: OptionDomain;
398
+ anchor: string;
399
+ relation: "before" | "after";
400
+ }> = {
401
+ insertSegmentFrom: fromSpec("persist"),
402
+ anchor: layoutNameSpec("anchor"),
403
+ relation: relationSpec(),
404
+ };
346
405
 
347
406
  // [LAW:types-are-the-program] A bounded step is fully described by an integer
348
407
  // domain (min < max) and a non-zero integer increment (`by`; negative for a
@@ -395,12 +454,25 @@ interface ValueSourceArm {
395
454
  readonly parse: ArmParse<Partial<ActionDecl>>;
396
455
  }
397
456
 
457
+ // [LAW:no-mode-explosion] `detectKeys` narrows WHICH of an arm's fields the
458
+ // present-count dispatch keys off, independent of `allowed`/`label` (still
459
+ // the full field set — what the arm PERMITS and is NAMED by never changes).
460
+ // Every arm before insertSegmentFrom had a field set disjoint from every
461
+ // other arm's, so `Object.keys(fieldMap)` was a safe default for both jobs
462
+ // at once. insertSegmentFrom breaks that: it shares `anchor`/`relation` with
463
+ // insertSegment (same POSITION shape, different segment-name SOURCE), so
464
+ // dispatching on the full set would make an ordinary `insertSegment` action
465
+ // spuriously match both arms via those shared keys. Pass the true
466
+ // discriminator (the field no sibling arm carries) here; omit it when the
467
+ // field set already is disjoint from every sibling, as it is everywhere else.
398
468
  function valueSourceArm<P extends object>(
399
469
  discriminator: "set" | "persist",
400
470
  fieldMap: FieldSpecMap<P>,
401
- ...checks: ReadonlyArray<Refinement<P>>
471
+ checks: ReadonlyArray<Refinement<P>> = [],
472
+ detectKeys?: readonly string[],
402
473
  ): ValueSourceArm {
403
- const detect = Object.keys(fieldMap);
474
+ const fullKeys = Object.keys(fieldMap);
475
+ const detect = detectKeys ?? fullKeys;
404
476
  const inner: ArmParse<P> = (ctx, path, raw) =>
405
477
  fields(ctx, fieldMap, path, raw);
406
478
  const source = objectJson(fieldMap) as {
@@ -409,8 +481,8 @@ function valueSourceArm<P extends object>(
409
481
  };
410
482
  return {
411
483
  detect,
412
- allowed: [discriminator, ...detect],
413
- label: detect.join("/"),
484
+ allowed: [discriminator, ...fullKeys],
485
+ label: fullKeys.join("/"),
414
486
  json: {
415
487
  type: "object",
416
488
  properties: { [discriminator]: { type: "string" }, ...source.properties },
@@ -430,7 +502,7 @@ function valueSourceArm<P extends object>(
430
502
  const SET_ARMS: readonly ValueSourceArm[] = [
431
503
  valueSourceArm("set", TO_FIELDS_SET),
432
504
  valueSourceArm("set", FROM_FIELDS_SET),
433
- valueSourceArm("set", BOUNDED_FIELDS, minLessThanMax, byNonZero),
505
+ valueSourceArm("set", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
434
506
  valueSourceArm("set", INT_FIELDS),
435
507
  valueSourceArm("set", CYCLE_FIELDS_SET),
436
508
  ];
@@ -442,10 +514,21 @@ const SET_ARMS: readonly ValueSourceArm[] = [
442
514
  const PERSIST_ARMS: readonly ValueSourceArm[] = [
443
515
  valueSourceArm("persist", TO_FIELDS_PERSIST),
444
516
  valueSourceArm("persist", FROM_FIELDS_PERSIST),
445
- valueSourceArm("persist", BOUNDED_FIELDS, minLessThanMax, byNonZero),
517
+ valueSourceArm("persist", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
446
518
  valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
447
519
  valueSourceArm("persist", REMOVE_SEGMENT_FIELDS),
448
- valueSourceArm("persist", INSERT_SEGMENT_FIELDS),
520
+ // [LAW:no-mode-explosion] Both insertSegment arms narrow detectKeys to
521
+ // their own discriminating field — see valueSourceArm's own comment. They
522
+ // share "anchor"/"relation" (same position shape, different segment-name
523
+ // source), so dispatching on the full field set would make EITHER arm
524
+ // spuriously match an action declaring the other.
525
+ valueSourceArm("persist", INSERT_SEGMENT_FIELDS, [], ["insertSegment"]),
526
+ valueSourceArm(
527
+ "persist",
528
+ INSERT_SEGMENT_FROM_FIELDS,
529
+ [],
530
+ ["insertSegmentFrom"],
531
+ ),
449
532
  ];
450
533
 
451
534
  // [LAW:one-source-of-truth] The clause list, not the joined string, is the
@@ -466,6 +549,7 @@ function valueSourceClauses(discriminator: "set" | "persist"): string[] {
466
549
  clauses.push(
467
550
  `"removeSegment" (remove a named segment from the layout)`,
468
551
  `"insertSegment"/"anchor"/"relation" (insert a named segment before/after an existing one)`,
552
+ `"insertSegmentFrom"/"anchor"/"relation" (insert a segment PICKED from an option domain before/after an existing one)`,
469
553
  );
470
554
  }
471
555
  return clauses;
@@ -590,7 +674,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
590
674
  const at = `${path}.${field}`;
591
675
  if (typeof from === "string") {
592
676
  if (from === "") {
593
- issue(ctx, at, `from must be a non-empty domain name`);
677
+ issue(ctx, at, `${field} must be a non-empty domain name`);
594
678
  return undefined;
595
679
  }
596
680
  return from;
@@ -601,7 +685,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
601
685
  issue(
602
686
  ctx,
603
687
  at,
604
- `from must name a domain (a non-empty string) or declare an inline domain (a non-empty array of values)`,
688
+ `${field} must name a domain (a non-empty string) or declare an inline domain (a non-empty array of values)`,
605
689
  );
606
690
  return undefined;
607
691
  }
@@ -609,7 +693,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
609
693
  issue(
610
694
  ctx,
611
695
  at,
612
- `from array members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
696
+ `${field} array members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
613
697
  );
614
698
  return undefined;
615
699
  }
@@ -618,7 +702,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
618
702
  issue(
619
703
  ctx,
620
704
  at,
621
- `from array member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
705
+ `${field} array member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
622
706
  );
623
707
  return undefined;
624
708
  }
@@ -626,7 +710,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
626
710
  issue(
627
711
  ctx,
628
712
  at,
629
- `from array members must be unique — a duplicated value would render the same picker option twice`,
713
+ `${field} array members must be unique — a duplicated value would render the same picker option twice`,
630
714
  );
631
715
  return undefined;
632
716
  }
@@ -635,7 +719,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
635
719
  issue(
636
720
  ctx,
637
721
  at,
638
- `from must be a domain name (a string) or an inline domain (an array of strings), got ${describeValue(from)}`,
722
+ `${field} must be a domain name (a string) or an inline domain (an array of strings), got ${describeValue(from)}`,
639
723
  );
640
724
  return undefined;
641
725
  },
@@ -15,8 +15,10 @@ import {
15
15
  } from "../dsl-types.js";
16
16
  import {
17
17
  actionBindsPersist,
18
+ actionBindsRedo,
18
19
  actionBindsReset,
19
20
  actionBindsSet,
21
+ actionBindsUndo,
20
22
  type ActionDecl,
21
23
  } from "../action.js";
22
24
  import {
@@ -394,10 +396,14 @@ function checkPresetRootOpsTarget(
394
396
  if (discriminator === "reset") return;
395
397
  const hasRemove = "removeSegment" in a;
396
398
  const hasInsert = "insertSegment" in a;
397
- 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) {
398
404
  ctx.issues.push({
399
405
  path: at,
400
- 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)`,
401
407
  line,
402
408
  });
403
409
  return;
@@ -427,6 +433,13 @@ function checkPresetRootOpsTarget(
427
433
  });
428
434
  }
429
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
+ }
430
443
  }
431
444
 
432
445
  function hasStateKind(cfg: DslConfig): boolean {
@@ -442,15 +455,22 @@ function hasStateKind(cfg: DslConfig): boolean {
442
455
  return false;
443
456
  }
444
457
 
445
- // [LAW:dataflow-not-control-flow] A config emits a set-state, set-config, OR
446
- // reset-config click — and so needs session.id — when any declared action is
447
- // a `set` (literal/option/bounded/cycle), a `persist` (its config-overrides
448
- // twin), or a `reset` (persist's gated undo) all three carry session.id on
449
- // the wire for click-error surfacing. copy/open actions write nothing, so
450
- // they embed no session.id.
458
+ // [LAW:dataflow-not-control-flow] A config emits a set-state, set-config,
459
+ // reset-config, undo, OR redo click — and so needs session.id — when any
460
+ // declared action is a `set` (literal/option/bounded/cycle), a `persist`
461
+ // (its config-overrides twin), a `reset` (persist's gated undo), or an
462
+ // `undo`/`redo` (the overrides layer's global history step) all five
463
+ // carry session.id on the wire for click-error surfacing (an empty history
464
+ // stack is a loud, session-scoped miss, not a silent no-op). copy/open
465
+ // actions write nothing, so they embed no session.id.
451
466
  function hasActionSetAction(cfg: DslConfig): boolean {
452
467
  return Object.values(cfg.actions).some(
453
- (a) => actionBindsSet(a) || actionBindsPersist(a) || actionBindsReset(a),
468
+ (a) =>
469
+ actionBindsSet(a) ||
470
+ actionBindsPersist(a) ||
471
+ actionBindsReset(a) ||
472
+ actionBindsUndo(a) ||
473
+ actionBindsRedo(a),
454
474
  );
455
475
  }
456
476
 
@@ -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
+ }