@promptctl/cc-candybar 1.36.0 → 1.38.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.
@@ -16,12 +16,13 @@
16
16
  // `persist` action's keyspace) — two keyspaces, one mechanism.
17
17
 
18
18
  import { listResolvablePaletteNames, STRIP_STYLES } from "../../themes/policy";
19
- import type { ActionDecl } from "../../config/action";
19
+ import { actionDestinations, type ActionDecl } from "../../config/action";
20
20
  import {
21
21
  perConfigDomainsFor,
22
22
  resolveOptionDomain,
23
23
  } from "../../config/option-domain";
24
24
  import type { DslConfig } from "../../config/dsl-types";
25
+ import { numericGlobalsSeeds } from "../../config/loader/globals";
25
26
  import {
26
27
  clampSeed,
27
28
  createValidatorRegistry,
@@ -191,10 +192,18 @@ function dropBaselineAllowLists(
191
192
  );
192
193
  }
193
194
 
194
- // [LAW:one-source-of-truth] Each `state` variable's integer `default` is the
195
- // initial value of its key — the value the bar renders before any click. The
196
- // step-state handler must seed an unset key from the SAME number, so the
197
- // derived range spec carries it.
195
+ // [LAW:one-source-of-truth] The value a bounded key renders with before any
196
+ // click, from the two places that can define it: a `state` variable's integer
197
+ // `default` (the only source for a key of the config's own invention, like a
198
+ // hue stepper), and — winning for the fields it covers — what the config
199
+ // resolves for a GLOBALS field, since a session stepper over `padding` starts
200
+ // from the padding the bar is showing, not from a state var nobody declared.
201
+ //
202
+ // The globals half is the SAME function the config-overrides gate seeds from
203
+ // (numericGlobalsSeeds), so a session stepper and its durable twin cannot
204
+ // start from different numbers. Before it existed, the settings menu's session
205
+ // padding stepper seeded from `min`: a bar reading `padding 1` answered its
206
+ // first ◀ by wrapping to 16.
198
207
  function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
199
208
  const seeds = new Map<string, number>();
200
209
  const INT_RE = /^-?\d+$/;
@@ -205,6 +214,9 @@ function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
205
214
  seeds.set(decl.key, parseInt(raw, 10));
206
215
  }
207
216
  }
217
+ for (const [key, seed] of numericGlobalsSeeds(config.globals)) {
218
+ seeds.set(key, seed);
219
+ }
208
220
  return seeds;
209
221
  }
210
222
 
@@ -214,10 +226,16 @@ function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
214
226
  function actionContributions(config: DslConfig): KeySpecContribution[] {
215
227
  const seeds = stateKeySeeds(config);
216
228
  const perConfigDomains = perConfigDomainsFor(config);
229
+ // [LAW:single-enforcer] Every action is exploded into the
230
+ // single-destination declarations it writes through BEFORE the fold, so a
231
+ // dual-destination action (candybar-settings-ui-aok.3) contributes exactly
232
+ // the `set` spec its session half would have contributed on its own — the
233
+ // gate is derived by the code that has always derived it, from the same
234
+ // declaration the click realizes, and a dual can widen nothing.
217
235
  return dropBaselineAllowLists(
218
- Object.values(config.actions).flatMap((a) =>
219
- actionKeySpecs(a, seeds, perConfigDomains),
220
- ),
236
+ Object.values(config.actions)
237
+ .flatMap(actionDestinations)
238
+ .flatMap((a) => actionKeySpecs(a, seeds, perConfigDomains)),
221
239
  );
222
240
  }
223
241
 
package/src/demo/dsl.ts CHANGED
@@ -33,6 +33,8 @@ import { listResolvablePaletteNames } from "../themes/policy.js";
33
33
  import {
34
34
  effectiveThemeName,
35
35
  effectiveLookName,
36
+ effectiveAutoWrap,
37
+ effectivePadding,
36
38
  lookKeyByName,
37
39
  paletteForThemeName,
38
40
  } from "../themes/index.js";
@@ -41,9 +43,7 @@ import { registerDslConfig, renderDsl } from "../dsl/render.js";
41
43
  import {
42
44
  DEFAULT_CHARSET,
43
45
  DEFAULT_COLOR_COMPATIBILITY,
44
- DEFAULT_PADDING,
45
46
  DEFAULT_TERMINAL_WIDTH,
46
- DEFAULT_WRAP,
47
47
  } from "../render/strip.js";
48
48
  import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
49
49
 
@@ -139,10 +139,11 @@ try {
139
139
  width: applyClaudeCodeReserve(
140
140
  process.stdout.columns ?? DEFAULT_TERMINAL_WIDTH,
141
141
  ),
142
- // Same resolution the daemon applies: the config global over the
143
- // default-on floor.
144
- wrap: globals.autoWrap ?? DEFAULT_WRAP,
145
- padding: globals.padding ?? DEFAULT_PADDING,
142
+ // Same resolvers the daemon applies, with a null session pick — the
143
+ // demo has no SessionState, so both land on the config default over
144
+ // their floor.
145
+ wrap: effectiveAutoWrap(null, globals.autoWrap),
146
+ padding: effectivePadding(null, globals.padding),
146
147
  charset: globals.charset ?? DEFAULT_CHARSET,
147
148
  },
148
149
  undefined,
package/src/help-text.ts CHANGED
@@ -23,9 +23,12 @@ Configuration:
23
23
  to point at a specific file. See the default config for all available options:
24
24
  node dist/index.mjs debug --project-dir . --cwd .
25
25
 
26
- The bundled default bar ships a settings drawer — no config needed. Click
27
- ⚙ settings ${DISCLOSURE_GLYPH_CLOSED} on the bar to reveal a clickable theme/look picker and
28
- other display options.
26
+ Every bar carries a settings menu, whatever your config says — no config
27
+ needed, and writing your own \`root\` cannot delete it. Click
28
+ ☰ ${DISCLOSURE_GLYPH_CLOSED} on the bar for preset switching, edit mode, and a config menu
29
+ of clickable theme/look/style/wrap/padding controls. The \`persist?\`
30
+ checkbox there chooses where a change lands: unchecked it applies to this
31
+ session only, checked it becomes the default every session opens with.
29
32
 
30
33
  Subcommands:
31
34
  install One-shot setup: stages the runtime (native render
@@ -451,8 +451,8 @@ function installSuccessMessage(): string {
451
451
  return (
452
452
  `✓ install complete.\n` +
453
453
  ` Restart Claude Code to pick up the new statusline.\n` +
454
- ` Tip: the default bar has a settings drawer — click ⚙ settings\n` +
455
- ` ${DISCLOSURE_GLYPH_CLOSED} to reveal a clickable theme/look picker and other display options.\n`
454
+ ` Tip: every bar carries a settings menu — click ☰ ${DISCLOSURE_GLYPH_CLOSED} for preset\n` +
455
+ ` switching, edit mode, and clickable theme/look/style/wrap/padding controls.\n`
456
456
  );
457
457
  }
458
458
 
@@ -25,10 +25,15 @@ 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 } from "../config/action.js";
28
+ import {
29
+ actionDestinations,
30
+ actionIsDual,
31
+ PERSIST_WHEN,
32
+ type ActionDecl,
33
+ } from "../config/action.js";
29
34
  import { resolveOptionDomain } from "../config/option-domain.js";
30
35
  import { encodeLayoutOp, type LayoutOp } from "../config/layout-ops.js";
31
- import type { StripStyle } from "../themes/policy.js";
36
+ import { parseSessionBoolean, type StripStyle } from "../themes/policy.js";
32
37
  import {
33
38
  effectsUrl,
34
39
  VERB_APPLY_LAYOUT_OP,
@@ -166,7 +171,24 @@ export type CompiledActionDecl =
166
171
  // there is nothing to carry, since the history stack (not this action) is
167
172
  // what decides which entry moves.
168
173
  | { readonly kind: "undo" }
169
- | { readonly kind: "redo" };
174
+ | { readonly kind: "redo" }
175
+ // [LAW:dataflow-not-control-flow] candybar-settings-ui-aok.3's ONE control
176
+ // per setting. Both destinations are compiled here as the ordinary
177
+ // single-destination shapes they are, and `selector` names the session key
178
+ // whose boolean value picks between them at click time. The destination is
179
+ // therefore a VALUE flowing through `activeDestination` — every consumer
180
+ // (realize, the picker, selectDisplay) resolves it once at the top and then
181
+ // runs the code it has always run, so nothing downstream branches on
182
+ // "is this dual".
183
+ | {
184
+ readonly kind: "dual";
185
+ readonly selector: string;
186
+ readonly session: CompiledActionDecl;
187
+ readonly durable: CompiledActionDecl;
188
+ // The SessionState key the session half writes, carried so a durable
189
+ // click can clear it in the same dispatch (see realize's dual arm).
190
+ readonly sessionKey: string;
191
+ };
170
192
 
171
193
  export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
172
194
 
@@ -185,6 +207,13 @@ export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
185
207
  // highlight is inert (readVar falls back to "" since no such var exists).
186
208
  const CONFIG_KEY_TO_EFFECTIVE_VAR: ReadonlyMap<string, string> = new Map([
187
209
  ["palette", "theme.effective"],
210
+ // [LAW:one-source-of-truth] `preset` earns its entry here the moment a DUAL
211
+ // control writes it: compileDual makes BOTH halves read back through this
212
+ // map, so a field missing from it loses its current-selection mark on the
213
+ // session side too — and the preset picker sits on the settings menu's
214
+ // always-visible first row, where "which arrangement am I in" is the whole
215
+ // question the control answers.
216
+ ["preset", "preset.effective"],
188
217
  ["look", "look.effective"],
189
218
  ["style", "style.effective"],
190
219
  ["charset", "charset.effective"],
@@ -263,6 +292,20 @@ function compileAction(
263
292
  stateKeyToVar: ReadonlyMap<string, string>,
264
293
  perConfigDomains: ReadonlyMap<string, readonly string[]>,
265
294
  ): CompiledActionDecl {
295
+ // [LAW:one-source-of-truth] A dual compiles as its own two destinations —
296
+ // the SAME explosion the validator derivations fold over
297
+ // (actionDestinations), so the click a dual realizes and the gate it derives
298
+ // come from one statement of what the two halves are. It is matched BEFORE
299
+ // the `set` arm because a dual carries `set` too.
300
+ if (actionIsDual(action)) {
301
+ const [session, durable] = actionDestinations(action);
302
+ return compileDual(
303
+ stateKeyToVar.get(action[PERSIST_WHEN]) ?? action[PERSIST_WHEN],
304
+ action.set,
305
+ compileAction(parse, name, session!, stateKeyToVar, perConfigDomains),
306
+ compileAction(parse, name, durable!, stateKeyToVar, perConfigDomains),
307
+ );
308
+ }
266
309
  if ("set" in action) {
267
310
  const stateVar = stateKeyToVar.get(action.set) ?? action.set;
268
311
  if ("to" in action) {
@@ -379,6 +422,55 @@ function compileAction(
379
422
  return "undo" in action ? { kind: "undo" } : { kind: "redo" };
380
423
  }
381
424
 
425
+ // [LAW:one-source-of-truth] A dual control shows ONE current value and writes
426
+ // relative to the value it showed — so both destinations read back through the
427
+ // DURABLE half's variable, which is the `.effective` projection the daemon
428
+ // resolved for this render (CONFIG_KEY_TO_EFFECTIVE_VAR above): the value the
429
+ // bar is actually rendering with, whatever chain produced it. Reading the
430
+ // session key instead would let a cycle's glyph name the effective state while
431
+ // its click stepped from an unwritten session key — the toggle would render
432
+ // "wrap: off" and write "false", a click that visibly does nothing. Arms that
433
+ // carry no `stateVar` (the bounded steppers) read nothing at render by design:
434
+ // their step is relative and resolved daemon-side.
435
+ function compileDual(
436
+ selectorVar: string,
437
+ sessionKey: string,
438
+ session: CompiledActionDecl,
439
+ durable: CompiledActionDecl,
440
+ ): CompiledActionDecl {
441
+ const readBack =
442
+ "stateVar" in session && "stateVar" in durable
443
+ ? { ...session, stateVar: durable.stateVar }
444
+ : session;
445
+ return {
446
+ kind: "dual",
447
+ selector: selectorVar,
448
+ session: readBack,
449
+ durable,
450
+ sessionKey,
451
+ };
452
+ }
453
+
454
+ // [LAW:dataflow-not-control-flow] THE destination fold: which store a dual
455
+ // action writes is the boolean value of its selector key, read from the same
456
+ // live store the rest of the render reads. Total over every compiled action —
457
+ // a single-destination action IS its own destination — so callers resolve
458
+ // through it unconditionally and never test for the dual kind.
459
+ //
460
+ // [LAW:one-source-of-truth] `parseSessionBoolean` is the one spelling of a
461
+ // boolean in SessionState (themes/policy.ts), the same parse `autoWrap`'s own
462
+ // session half goes through: an unwritten, malformed, or "false" selector all
463
+ // mean the session destination, and only a canonical "true" means durable.
464
+ export function activeDestination(
465
+ c: CompiledActionDecl,
466
+ store: VariableStore,
467
+ ): CompiledActionDecl {
468
+ if (c.kind !== "dual") return c;
469
+ return parseSessionBoolean(readVar(store, c.selector)) === true
470
+ ? c.durable
471
+ : c.session;
472
+ }
473
+
382
474
  function parseActionTemplate(
383
475
  parse: (src: string) => Template<RichText>,
384
476
  src: string,
@@ -463,18 +555,18 @@ function cycleIndex(
463
555
  // cell of an option picker, is pure waste). set-* arms read individual vars
464
556
  // directly. This is data locality, not a control-flow guard: the scope simply
465
557
  // flows into the arms that need it.
466
- function realize(
558
+ export function realize(
467
559
  c: CompiledActionDecl,
468
560
  display: string,
469
561
  boundValue: string | undefined,
470
562
  store: VariableStore,
471
563
  sessionId: string,
472
- ): { effect: Effect; active: boolean } {
564
+ ): { effects: readonly Effect[]; active: boolean } {
473
565
  switch (c.kind) {
474
566
  case "set-literal": {
475
567
  const current = readVar(store, c.stateVar);
476
568
  return {
477
- effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, c.value] },
569
+ effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, c.value] }],
478
570
  active: current === c.value,
479
571
  };
480
572
  }
@@ -482,7 +574,7 @@ function realize(
482
574
  const value = boundValue ?? display;
483
575
  const current = readVar(store, c.stateVar);
484
576
  return {
485
- effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, value] },
577
+ effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, value] }],
486
578
  active: current === value,
487
579
  };
488
580
  }
@@ -499,7 +591,7 @@ function realize(
499
591
  const value = boundValue ?? display;
500
592
  const current = readVar(store, c.stateVar);
501
593
  return {
502
- effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, value] },
594
+ effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, value] }],
503
595
  active: current === value,
504
596
  };
505
597
  }
@@ -510,7 +602,7 @@ function realize(
510
602
  // promised.
511
603
  const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
512
604
  return {
513
- effect: { verb: VERB_SET_STATE, args: [sessionId, c.key, next] },
605
+ effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, next] }],
514
606
  active: false,
515
607
  };
516
608
  }
@@ -522,27 +614,33 @@ function realize(
522
614
  // single range gate. So the link is byte-identical across renders and N
523
615
  // rapid clicks each accumulate (the idempotent absolute-write bug is gone).
524
616
  return {
525
- effect: {
526
- verb: VERB_STEP_STATE,
527
- args: [sessionId, c.key, String(c.by)],
528
- },
617
+ effects: [
618
+ {
619
+ verb: VERB_STEP_STATE,
620
+ args: [sessionId, c.key, String(c.by)],
621
+ },
622
+ ],
529
623
  active: false,
530
624
  };
531
625
  }
532
626
  case "copy":
533
627
  return {
534
- effect: {
535
- verb: VERB_COPY,
536
- args: [evalTemplate(c.text, buildScope(store))],
537
- },
628
+ effects: [
629
+ {
630
+ verb: VERB_COPY,
631
+ args: [evalTemplate(c.text, buildScope(store))],
632
+ },
633
+ ],
538
634
  active: false,
539
635
  };
540
636
  case "open":
541
637
  return {
542
- effect: {
543
- verb: VERB_OPEN_VSCODE,
544
- args: [evalTemplate(c.target, buildScope(store))],
545
- },
638
+ effects: [
639
+ {
640
+ verb: VERB_OPEN_VSCODE,
641
+ args: [evalTemplate(c.target, buildScope(store))],
642
+ },
643
+ ],
546
644
  active: false,
547
645
  };
548
646
  // [LAW:one-source-of-truth] The persist-* arms mirror set-*'s realization
@@ -553,7 +651,7 @@ function realize(
553
651
  case "persist-literal": {
554
652
  const current = readVar(store, c.stateVar);
555
653
  return {
556
- effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, c.value] },
654
+ effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, c.value] }],
557
655
  active: current === c.value,
558
656
  };
559
657
  }
@@ -561,29 +659,31 @@ function realize(
561
659
  const value = boundValue ?? display;
562
660
  const current = readVar(store, c.stateVar);
563
661
  return {
564
- effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, value] },
662
+ effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, value] }],
565
663
  active: current === value,
566
664
  };
567
665
  }
568
666
  case "persist-cycle": {
569
667
  const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
570
668
  return {
571
- effect: { verb: VERB_SET_CONFIG, args: [sessionId, c.key, next] },
669
+ effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, next] }],
572
670
  active: false,
573
671
  };
574
672
  }
575
673
  case "persist-bounded": {
576
674
  return {
577
- effect: {
578
- verb: VERB_STEP_CONFIG,
579
- args: [sessionId, c.key, String(c.by)],
580
- },
675
+ effects: [
676
+ {
677
+ verb: VERB_STEP_CONFIG,
678
+ args: [sessionId, c.key, String(c.by)],
679
+ },
680
+ ],
581
681
  active: false,
582
682
  };
583
683
  }
584
684
  case "reset":
585
685
  return {
586
- effect: { verb: VERB_RESET_CONFIG, args: [sessionId, c.key] },
686
+ effects: [{ verb: VERB_RESET_CONFIG, args: [sessionId, c.key] }],
587
687
  active: false,
588
688
  };
589
689
  // [LAW:one-source-of-truth] No key to carry — the click just says "step
@@ -592,12 +692,12 @@ function realize(
592
692
  // a history step is a one-shot trigger, not a current-selection toggle.
593
693
  case "undo":
594
694
  return {
595
- effect: { verb: VERB_UNDO, args: [sessionId] },
695
+ effects: [{ verb: VERB_UNDO, args: [sessionId] }],
596
696
  active: false,
597
697
  };
598
698
  case "redo":
599
699
  return {
600
- effect: { verb: VERB_REDO, args: [sessionId] },
700
+ effects: [{ verb: VERB_REDO, args: [sessionId] }],
601
701
  active: false,
602
702
  };
603
703
  // [LAW:one-source-of-truth] The op is fixed at compile time (see
@@ -608,10 +708,12 @@ function realize(
608
708
  // trigger, not a current-selection toggle.
609
709
  case "layout-op":
610
710
  return {
611
- effect: {
612
- verb: VERB_APPLY_LAYOUT_OP,
613
- args: [sessionId, c.key, encodeLayoutOp(c.op)],
614
- },
711
+ effects: [
712
+ {
713
+ verb: VERB_APPLY_LAYOUT_OP,
714
+ args: [sessionId, c.key, encodeLayoutOp(c.op)],
715
+ },
716
+ ],
615
717
  active: false,
616
718
  };
617
719
  // [LAW:one-source-of-truth] The picked option (boundValue ?? display — the
@@ -620,6 +722,46 @@ function realize(
620
722
  // emits, so the daemon's apply-layout-op handler and undo/redo need no
621
723
  // knowledge of where the segment name came from. Never "active": a
622
724
  // structural edit is a one-shot trigger, not a current-selection toggle.
725
+ // [LAW:dataflow-not-control-flow] The destination is resolved to a value
726
+ // and the SAME fold runs on it — a dual's realization is its chosen
727
+ // half's realization, with nothing about persistence duplicated here.
728
+ // Depth is structurally one: a dual's halves are the single-destination
729
+ // decls actionDestinations built, which can never be dual themselves.
730
+ //
731
+ // [LAW:no-silent-failure] A DURABLE click carries the session key to
732
+ // RELEASE as a trailing arg on its own write, so the daemon drops it only
733
+ // after that write succeeded. Without the release the write would be
734
+ // invisible to the session that made it — every settable global resolves
735
+ // session pick OVER durable default, so the workflow this menu invites
736
+ // ("try it here, then tick persist? to commit it") would set a default the
737
+ // user cannot see and leave the control dead for the rest of the session.
738
+ // Riding the write rather than sitting beside it is what makes the pair
739
+ // unsplittable: a click runs every effect it carries, so a rejected write
740
+ // must not be able to drop the pick on its own.
741
+ case "dual": {
742
+ const chosen = activeDestination(c, store);
743
+ const { effects, active } = realize(
744
+ chosen,
745
+ display,
746
+ boundValue,
747
+ store,
748
+ sessionId,
749
+ );
750
+ // The durable write carries the session key to RELEASE as one more
751
+ // segment on itself, so the daemon clears it only after its own write
752
+ // succeeded. A second effect beside it would not do: `dispatch` runs
753
+ // every effect in a click by design, so a rejected write would still
754
+ // wipe the session pick and leave nothing durable in its place.
755
+ return chosen === c.durable
756
+ ? {
757
+ effects: effects.map((e) => ({
758
+ ...e,
759
+ args: [...e.args, c.sessionKey],
760
+ })),
761
+ active,
762
+ }
763
+ : { effects, active };
764
+ }
623
765
  case "layout-op-option": {
624
766
  const segment = boundValue ?? display;
625
767
  const op: LayoutOp = {
@@ -629,10 +771,12 @@ function realize(
629
771
  relation: c.relation,
630
772
  };
631
773
  return {
632
- effect: {
633
- verb: VERB_APPLY_LAYOUT_OP,
634
- args: [sessionId, c.key, encodeLayoutOp(op)],
635
- },
774
+ effects: [
775
+ {
776
+ verb: VERB_APPLY_LAYOUT_OP,
777
+ args: [sessionId, c.key, encodeLayoutOp(op)],
778
+ },
779
+ ],
636
780
  active: false,
637
781
  };
638
782
  }
@@ -682,24 +826,34 @@ export function renderAction(
682
826
  displays: readonly string[],
683
827
  runtime: ActionRuntime,
684
828
  ): RichText {
685
- const action = runtime.compiled.get(name);
829
+ const declared = runtime.compiled.get(name);
686
830
  // [LAW:no-defensive-null-guards] The loader validates every `{{ action "x" }}`
687
831
  // reference resolves to a declared action, and compileActions compiled every
688
832
  // declared action for THIS config's engine. A miss is a caller/wiring bug.
689
- if (!action) {
833
+ if (!declared) {
690
834
  throw new Error(`action "${name}" is not declared in this config`);
691
835
  }
692
836
  const store = runtime.store;
693
- const { display, boundValue } = selectDisplay(name, action, displays, store);
837
+ // [LAW:dataflow-not-control-flow] DISPLAY selection reads the resolved half
838
+ // (a cycle's glyph is the current member's, whichever store it will write),
839
+ // while REALIZATION is handed the declaration itself — a dual realizes as
840
+ // its chosen half PLUS the session clear that keeps a durable write visible,
841
+ // and that pairing belongs to the one fold that owns the union.
842
+ const { display, boundValue } = selectDisplay(
843
+ name,
844
+ activeDestination(declared, store),
845
+ displays,
846
+ store,
847
+ );
694
848
  const sessionId = readVar(store, "session.id");
695
- const { effect, active } = realize(
696
- action,
849
+ const { effects, active } = realize(
850
+ declared,
697
851
  display,
698
852
  boundValue,
699
853
  store,
700
854
  sessionId,
701
855
  );
702
- return linkFragment(display, effectsUrl([effect]), active);
856
+ return linkFragment(display, effectsUrl(effects), active);
703
857
  }
704
858
 
705
859
  // ─── FuncMap entry ─────────────────────────────────────────────────────────────
@@ -28,15 +28,11 @@ 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
32
  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";
38
- import {
33
+ activeDestination,
39
34
  linkFragment,
35
+ realize,
40
36
  readVar,
41
37
  type ActionRuntime,
42
38
  type CompiledActionDecl,
@@ -160,7 +156,15 @@ function requireOptionKind(
160
156
  CompiledActionDecl,
161
157
  { kind: "set-option" | "persist-option" | "layout-op-option" }
162
158
  > {
163
- const action = runtime.compiled.get(name);
159
+ // [LAW:dataflow-not-control-flow] A dual-destination action resolves to the
160
+ // half its selector names BEFORE the kind check, so a picker over a dual is
161
+ // a picker over whichever option kind is live — the grid, its current-mark,
162
+ // and its close folding are the code they already were. The check below then
163
+ // still names a real, single-destination kind in its error.
164
+ const declared = runtime.compiled.get(name);
165
+ const action = declared
166
+ ? activeDestination(declared, runtime.store)
167
+ : declared;
164
168
  if (
165
169
  !action ||
166
170
  (action.kind !== "set-option" &&
@@ -194,6 +198,13 @@ export function renderPicker(
194
198
  runtime: ActionRuntime,
195
199
  ): RichText {
196
200
  const apply = requireOptionKind(runtime, applyName);
201
+ // [LAW:one-source-of-truth] The GRID reads the resolved half above (its
202
+ // options, its current-mark); the CLICK is realized from the declaration
203
+ // itself, through the same fold `{{ action }}` uses. That is what carries a
204
+ // dual's session clear into a picked option — the picker never learns what a
205
+ // dual is, and there is no second projection of "what does this option
206
+ // write" to drift from realize's.
207
+ const declared = runtime.compiled.get(applyName)!;
197
208
  const store = runtime.store;
198
209
  const sessionId = readVar(store, "session.id");
199
210
  // [LAW:no-defensive-null-guards] layout-op-option carries no `stateVar` —
@@ -275,30 +286,21 @@ export function renderPicker(
275
286
  ? [{ verb: VERB_SET_STATE, args: [sessionId, ...closeFlat] }]
276
287
  : [];
277
288
  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
- ]);
289
+ const { effects } = realize(declared, option, option, store, sessionId);
290
+ // [LAW:one-source-of-truth] A plain session pick folds its close pairs into
291
+ // the SAME set-state (setState is variadic), so closing and applying are
292
+ // one write. Every other shape — a durable write, a structural op, a dual
293
+ // carrying its session clear — takes more than one effect already, so its
294
+ // close rides as its own effect in the same atomic dispatch.
295
+ const solo = effects.length === 1 ? effects[0]! : undefined;
296
+ return solo?.verb === VERB_SET_STATE
297
+ ? effectsUrl([
298
+ {
299
+ verb: VERB_SET_STATE,
300
+ args: [...solo.args, ...(closeOnPick ? closeFlat : [])],
301
+ },
302
+ ])
303
+ : effectsUrl([...effects, ...closeEffect]);
302
304
  };
303
305
 
304
306
  const frags: RichText[] = [linkFragment(PICKER_CLOSE, closeUrl, false)];
@@ -37,16 +37,12 @@ export type { Charset, ColorCompatibility, StripStyle };
37
37
  // applyClaudeCodeReserve from src/utils/terminal-width).
38
38
  export const DEFAULT_TERMINAL_WIDTH = 120;
39
39
 
40
- // [LAW:one-source-of-truth] The one statement of the globals.autoWrap
41
- // default (on — current behavior). Every resolver of the config global
42
- // (`globals.autoWrap ?? DEFAULT_WRAP`) derives from this constant.
43
- export const DEFAULT_WRAP = true;
44
-
45
- // [LAW:one-source-of-truth] The one statement of the globals.padding
46
- // default (one space per side inside each segment cell — current behavior,
47
- // matching the legacy display.padding). Every resolver of the config global
48
- // (`globals.padding ?? DEFAULT_PADDING`) derives from this constant.
49
- export const DEFAULT_PADDING = 1;
40
+ // [LAW:one-source-of-truth] autoWrap's and padding's defaults live in
41
+ // themes/policy.ts beside their resolvers and padding's range, because the
42
+ // config loader needs them too and config must not import render
43
+ // [LAW:one-way-deps]. Re-exported here on the same terms as the types above, so
44
+ // render-layer consumers keep importing them from the strip module.
45
+ export { DEFAULT_WRAP, DEFAULT_PADDING } from "../themes/policy.js";
50
46
 
51
47
  // [LAW:one-source-of-truth] The one statement of the globals.charset default
52
48
  // (powerline unicode glyphs — current behavior, matching the legacy