@promptctl/cc-candybar 1.31.0 → 1.33.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.
@@ -388,6 +388,20 @@ const INSERT_SEGMENT_FIELDS: FieldSpecMap<{
388
388
  anchor: layoutNameSpec("anchor"),
389
389
  relation: relationSpec(),
390
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
+ };
391
405
 
392
406
  // [LAW:types-are-the-program] A bounded step is fully described by an integer
393
407
  // domain (min < max) and a non-zero integer increment (`by`; negative for a
@@ -440,12 +454,25 @@ interface ValueSourceArm {
440
454
  readonly parse: ArmParse<Partial<ActionDecl>>;
441
455
  }
442
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.
443
468
  function valueSourceArm<P extends object>(
444
469
  discriminator: "set" | "persist",
445
470
  fieldMap: FieldSpecMap<P>,
446
- ...checks: ReadonlyArray<Refinement<P>>
471
+ checks: ReadonlyArray<Refinement<P>> = [],
472
+ detectKeys?: readonly string[],
447
473
  ): ValueSourceArm {
448
- const detect = Object.keys(fieldMap);
474
+ const fullKeys = Object.keys(fieldMap);
475
+ const detect = detectKeys ?? fullKeys;
449
476
  const inner: ArmParse<P> = (ctx, path, raw) =>
450
477
  fields(ctx, fieldMap, path, raw);
451
478
  const source = objectJson(fieldMap) as {
@@ -454,8 +481,8 @@ function valueSourceArm<P extends object>(
454
481
  };
455
482
  return {
456
483
  detect,
457
- allowed: [discriminator, ...detect],
458
- label: detect.join("/"),
484
+ allowed: [discriminator, ...fullKeys],
485
+ label: fullKeys.join("/"),
459
486
  json: {
460
487
  type: "object",
461
488
  properties: { [discriminator]: { type: "string" }, ...source.properties },
@@ -475,7 +502,7 @@ function valueSourceArm<P extends object>(
475
502
  const SET_ARMS: readonly ValueSourceArm[] = [
476
503
  valueSourceArm("set", TO_FIELDS_SET),
477
504
  valueSourceArm("set", FROM_FIELDS_SET),
478
- valueSourceArm("set", BOUNDED_FIELDS, minLessThanMax, byNonZero),
505
+ valueSourceArm("set", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
479
506
  valueSourceArm("set", INT_FIELDS),
480
507
  valueSourceArm("set", CYCLE_FIELDS_SET),
481
508
  ];
@@ -487,10 +514,21 @@ const SET_ARMS: readonly ValueSourceArm[] = [
487
514
  const PERSIST_ARMS: readonly ValueSourceArm[] = [
488
515
  valueSourceArm("persist", TO_FIELDS_PERSIST),
489
516
  valueSourceArm("persist", FROM_FIELDS_PERSIST),
490
- valueSourceArm("persist", BOUNDED_FIELDS, minLessThanMax, byNonZero),
517
+ valueSourceArm("persist", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
491
518
  valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
492
519
  valueSourceArm("persist", REMOVE_SEGMENT_FIELDS),
493
- 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
+ ),
494
532
  ];
495
533
 
496
534
  // [LAW:one-source-of-truth] The clause list, not the joined string, is the
@@ -511,6 +549,7 @@ function valueSourceClauses(discriminator: "set" | "persist"): string[] {
511
549
  clauses.push(
512
550
  `"removeSegment" (remove a named segment from the layout)`,
513
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)`,
514
553
  );
515
554
  }
516
555
  return clauses;
@@ -635,7 +674,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
635
674
  const at = `${path}.${field}`;
636
675
  if (typeof from === "string") {
637
676
  if (from === "") {
638
- issue(ctx, at, `from must be a non-empty domain name`);
677
+ issue(ctx, at, `${field} must be a non-empty domain name`);
639
678
  return undefined;
640
679
  }
641
680
  return from;
@@ -646,7 +685,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
646
685
  issue(
647
686
  ctx,
648
687
  at,
649
- `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)`,
650
689
  );
651
690
  return undefined;
652
691
  }
@@ -654,7 +693,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
654
693
  issue(
655
694
  ctx,
656
695
  at,
657
- `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`,
658
697
  );
659
698
  return undefined;
660
699
  }
@@ -663,7 +702,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
663
702
  issue(
664
703
  ctx,
665
704
  at,
666
- `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`,
667
706
  );
668
707
  return undefined;
669
708
  }
@@ -671,7 +710,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
671
710
  issue(
672
711
  ctx,
673
712
  at,
674
- `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`,
675
714
  );
676
715
  return undefined;
677
716
  }
@@ -680,7 +719,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
680
719
  issue(
681
720
  ctx,
682
721
  at,
683
- `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)}`,
684
723
  );
685
724
  return undefined;
686
725
  },
@@ -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) {