@promptctl/cc-candybar 1.38.0 → 1.40.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.
@@ -42,6 +42,7 @@ import {
42
42
  type MenuOptions,
43
43
  } from "../menu-keys.js";
44
44
  import {
45
+ cycleDisplayIssue,
45
46
  DISCLOSURE_CLOSED,
46
47
  disclosureCycleAction,
47
48
  disclosureStateVar,
@@ -60,13 +61,16 @@ import { reservedNamespaceCollisions } from "./reserved-namespace.js";
60
61
  const MENU_FUNC = "menu";
61
62
 
62
63
  // [LAW:types-are-the-program] The `{{ menu }}` surface, mirroring the render
63
- // helper's signature `menu "apply" [(dict …)]`: the apply name (identity member,
64
- // a required string literal) and ONE optional trailing options dict —
65
- // closeOnPick / paged / key, all statically readable via `staticDictEntries`.
66
- // The removed positional tail (page-action string, bare bools, 5th-arg key) is
67
- // detected and rejected with a migration-pointing error, never silently
64
+ // helper's signature `menu "apply" display… [(dict …)]`: the apply name
65
+ // (identity member, a required string literal), the trigger's authored display
66
+ // text (one per state or one static — the arity is statically countable, so it
67
+ // is checked here), and ONE optional trailing options dict — closeOnPick /
68
+ // paged / key, all statically readable via `staticDictEntries`. Displays
69
+ // themselves are NOT required to be literals; identity does not depend on
70
+ // them, exactly as a cycle `{{ action }}`'s displays are free. Every removed
71
+ // spelling is rejected with a migration-pointing error, never silently
68
72
  // reinterpreted [LAW:no-silent-failure].
69
- const MIGRATION = `the positional tail ("pageAction" closeOnPick paged "key") was removed: the page cursor is now synthesized from the menu's identity, and rare knobs are named options in ONE trailing dict — write {{ menu "applyTheme" }} or {{ menu "applyTheme" (dict "closeOnPick" true "paged" false "key" "pickers") }} (defaults: closeOnPick false, paged true, no key)`;
73
+ const MIGRATION = `a menu binds its trigger text the way a cycle action binds a display — write {{ menu "applyTheme" "▸" "▾" }} (one per state) or {{ menu "insertHere" "+" }} (one static display for both), with the rare knobs in ONE trailing dict: {{ menu "applyTheme" "▸" "▾" (dict "closeOnPick" true "paged" false "key" "pickers") }} (defaults: closeOnPick false, paged true, no key). The renderer no longer appends ▸/▾ of its own (candybar-settings-ui-aok.4), and the older positional tail ("pageAction" closeOnPick paged "key") was removed — the page cursor is synthesized from the menu's identity`;
70
74
 
71
75
  // [LAW:dataflow-not-control-flow] One total analysis of a `{{ menu }}` call site:
72
76
  // every reachable argument shape lands in exactly one arm — a usable identity
@@ -80,33 +84,80 @@ type MenuAnalysis =
80
84
  }
81
85
  | { readonly kind: "issue"; readonly message: string };
82
86
 
87
+ type ArgExpr = ReferencedCall["argExprs"][number];
88
+
89
+ const isDictCall = (e: ArgExpr): boolean =>
90
+ e.kind === "call" && e.name === "dict";
91
+
92
+ // [LAW:one-source-of-truth] The two sides split the tail on different evidence —
93
+ // exprs here, evaluated values in `parseMenuArgs` — so the loader admits only
94
+ // call sites where those two readings PROVABLY coincide. The renderer's split
95
+ // asks one question of the last value, "is it an object", so a literal answers
96
+ // it here: a parse-time constant evaluates to itself and can never become the
97
+ // options dict. A literal `(dict …)` always does. Everything else in that slot
98
+ // is classified by whatever it happens to evaluate to.
99
+ const isNonObjectLiteral = (e: ArgExpr): boolean =>
100
+ e.kind === "literal" && typeof e.value !== "object";
101
+
102
+ // The display-arity rule used as a predicate; the message is the caller's
103
+ // business, so the subject never surfaces. [LAW:single-enforcer] — legality is
104
+ // read off the disclosure primitive, never restated as a count comparison.
105
+ const legalDisplayCount = (count: number): boolean =>
106
+ cycleDisplayIssue("", count, 2) === undefined;
107
+
83
108
  function analyzeMenuCall(call: ReferencedCall): MenuAnalysis {
84
109
  const issue = (message: string): MenuAnalysis => ({ kind: "issue", message });
85
- const [applyArg, optsArg] = call.argExprs;
110
+ const [applyArg, ...tail] = call.argExprs;
86
111
  if (applyArg === undefined) {
87
112
  return issue(
88
- `with no arguments — it takes an apply-action name (e.g. {{ menu "applyTheme" }})`,
113
+ `with no arguments — it takes an apply-action name and its trigger text (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
89
114
  );
90
115
  }
91
- if (call.argExprs.length > 2) {
92
- return issue(`with more than two arguments — ${MIGRATION}`);
93
- }
94
116
  if (applyArg.kind !== "literal" || typeof applyArg.value !== "string") {
95
117
  return issue(
96
- `whose apply action is not a string literal — a menu's identity is its apply-action name, which must be a literal so it can be gated at load (e.g. {{ menu "applyTheme" }})`,
118
+ `whose apply action is not a string literal — a menu's identity is its apply-action name, which must be a literal so it can be gated at load (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
119
+ );
120
+ }
121
+ // [LAW:types-are-the-program] The dict is the LAST argument when present;
122
+ // everything before it is a display. Splitting on that one position is the
123
+ // whole grammar, and it is the same split `parseMenuArgs` performs on the
124
+ // evaluated tail at render — one shape, read twice from the two things each
125
+ // side has (exprs here, values there).
126
+ const last = tail[tail.length - 1];
127
+ const optsArg = last !== undefined && isDictCall(last) ? last : undefined;
128
+ const displays = optsArg === undefined ? tail : tail.slice(0, -1);
129
+ if (displays.some(isDictCall)) {
130
+ return issue(
131
+ `whose options (dict …) is not its last argument — ${MIGRATION}`,
97
132
  );
98
133
  }
134
+ // [LAW:no-silent-failure] The last slot is the one both readings can claim.
135
+ // When the expr there is not provably one or the other AND dropping it still
136
+ // leaves a legal display count, the renderer's value-based split can land on
137
+ // a DIFFERENT reading than this one — same call, two shapes, no error either
138
+ // side: the options dict skips `staticDictEntries` (so a dynamic `key` derives
139
+ // a state key with no synthesized var behind it, and the menu never opens) or
140
+ // a display vanishes into the static form. Reject that call site; an explicit
141
+ // trailing `(dict …)` disambiguates it and keeps dynamic displays legal.
142
+ // Where the alternate reading is an ILLEGAL count the renderer throws instead
143
+ // of diverging, so it stays accepted — loudness, not refusal, is the bar.
99
144
  if (
100
- optsArg !== undefined &&
101
- (optsArg.kind !== "call" || optsArg.name !== "dict")
145
+ last !== undefined &&
146
+ optsArg === undefined &&
147
+ !isNonObjectLiteral(last) &&
148
+ legalDisplayCount(displays.length - 1)
102
149
  ) {
103
- // A literal (the old page-action string / positional bool), a dynamic value,
104
- // or a non-dict call: none is an options dict — one migration error covers
105
- // the whole family [LAW:one-type-per-behavior].
106
150
  return issue(
107
- `whose second argument is not an options (dict …) — ${MIGRATION}`,
151
+ `whose last argument is neither a literal nor a literal (dict …) — the renderer tells a display from the options dict by the value it evaluates to, so this call could be read as ${displays.length} displays or as ${displays.length - 1} plus options, and both are legal. Make the options explicit as a trailing (dict …) — {{ menu "${applyArg.value}" (printf "…") (printf "…") (dict) }} binds dynamic displays unambiguously — or bind the trigger text as literals`,
108
152
  );
109
153
  }
154
+ // [LAW:single-enforcer] The display-arity rule is the disclosure primitive's,
155
+ // the same one the renderer picks through — checked HERE too because the
156
+ // count is statically known, so an unauthored trigger is a load error naming
157
+ // the fix rather than a diagnostic glyph on the next render.
158
+ // "whose trigger …" completes the caller's `segment "X" has a {{ menu }} `.
159
+ const arity = cycleDisplayIssue("whose trigger", displays.length, 2);
160
+ if (arity !== undefined) return issue(`${arity} — ${MIGRATION}`);
110
161
  const entries = optsArg === undefined ? {} : staticDictEntries(optsArg);
111
162
  if (entries === null) {
112
163
  return issue(
@@ -53,6 +53,12 @@ export function mergeWithDefault(
53
53
  // (incl. the "default" empty-fragment floor effectivePresetName collapses
54
54
  // to) survives every merge by construction, exactly as looks' "none" does.
55
55
  presets: { ...dflt.presets, ...(raw.presets ?? {}) },
56
+ // [LAW:one-source-of-truth] editGlobals merges FIELD by field — the
57
+ // `globals` cascade above, not the by-name cascades around it, because it
58
+ // IS a globals fragment: a user retuning edit mode's separator says nothing
59
+ // about its `style`, exactly as a user setting `globals.padding` says
60
+ // nothing about `globals.charset`.
61
+ editGlobals: { ...dflt.editGlobals, ...(raw.editGlobals ?? {}) },
56
62
  // [LAW:one-source-of-truth] helpers merge by name, same cascade — a user
57
63
  // overrides one formatter helper by re-declaring its name; the rest inherit
58
64
  // from the bundled default.
@@ -15,7 +15,7 @@
15
15
  // place it is written down:
16
16
  //
17
17
  // bundled default < user config file < persisted overrides
18
- // < ACTIVE PRESET < session pick
18
+ // < ACTIVE PRESET < session pick < EDIT MODE
19
19
  //
20
20
  // The preset's position is forced by its lifetime, not chosen. Everything to
21
21
  // its left is resolved once per RenderCache entry (an entry serves many
@@ -26,6 +26,18 @@
26
26
  // "compact" arrangement must actually change padding, even for a user who once
27
27
  // persisted a padding they liked) while a session's own click still wins over
28
28
  // the preset (a click is later still).
29
+ //
30
+ // The same rule places the last rung (candybar-settings-ui-aok.5): edit mode's
31
+ // `editGlobals` fragment is decided later than ANY session pick — a user picks
32
+ // a style, and only afterwards enters edit mode — so it is the new rightmost
33
+ // layer, and a session pick of "capsule" cannot survive into a mode whose whole
34
+ // job is to stop segments reading as one continuous strip. It differs from
35
+ // every rung to its left in LIFETIME rather than in kind: nothing writes it to
36
+ // SessionState or the overrides layer, so leaving edit mode restores the
37
+ // previous look with no save/restore path — the session's own pick was never
38
+ // overwritten, only out-ranked [LAW:dataflow-not-control-flow]. The rung itself
39
+ // is the `staged` parameter of effectiveGlobal (themes/policy.ts); this comment
40
+ // is the ONE place the order is written down.
29
41
 
30
42
  // [LAW:one-way-deps] Type-only, so nothing is emitted and option-domain.ts (a
31
43
  // leaf that deliberately never imports dsl-types.ts) can import PRESET_NAMES
@@ -87,7 +99,14 @@ export function effectivePresetName(
87
99
  globalsPreset: string | undefined,
88
100
  declaredPresets: Readonly<Record<string, PresetDecl>>,
89
101
  ): string {
102
+ // [LAW:types-are-the-program] No staged rung, and the absence is enforced
103
+ // rather than assumed: every globals fragment that could stage one — a
104
+ // preset's own `globals`, edit mode's `editGlobals` — has `preset` swapped for
105
+ // a rejection in its schema (loader/globals.ts), so "a fragment selected a
106
+ // preset" is unrepresentable and there is nothing here to resolve against.
107
+ // Which preset is active keeps exactly one authority.
90
108
  return effectiveMemberName(
109
+ undefined,
91
110
  sessionPreset,
92
111
  globalsPreset,
93
112
  PRESET_FLOOR,
@@ -578,7 +578,8 @@ function declareSettingControls(artifacts: MenuArtifacts): void {
578
578
  artifacts.segments[seg] = {
579
579
  template:
580
580
  `${c.glyph} {{ .${c.effectiveVar} }} ` +
581
- `{{ menu "${apply}" (dict "key" "${PICKER_KEY}" "closeOnPick" true) }} ` +
581
+ `{{ menu "${apply}" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" ` +
582
+ `(dict "key" "${PICKER_KEY}" "closeOnPick" true) }} ` +
582
583
  `{{ action "${controlReset(c.name)}" "↺" }}`,
583
584
  bg: "surface",
584
585
  fg: "foreground",
@@ -20,7 +20,20 @@ import path from "node:path";
20
20
  import os from "node:os";
21
21
  import type { ClaudeHookData } from "../utils/claude.js";
22
22
  import type { ClientHints } from "./protocol.js";
23
- import type { DslConfig, VariableDecl } from "../config/dsl-types.js";
23
+ import type { DslConfig, Globals, VariableDecl } from "../config/dsl-types.js";
24
+ import { effectivePresetName, presetGlobals } from "../config/presets.js";
25
+ import { EDIT_MODE_KEY, EDIT_MODE_OPEN } from "../config/loader/edit-mode.js";
26
+ import {
27
+ DEFAULT_CHARSET,
28
+ DEFAULT_COLOR_COMPATIBILITY,
29
+ } from "../render/strip.js";
30
+ import {
31
+ effectiveAutoWrap,
32
+ effectiveLookName,
33
+ effectivePadding,
34
+ effectiveStripStyle,
35
+ effectiveThemeName,
36
+ } from "../themes/policy.js";
24
37
  import { walkNodes } from "../config/dsl-types.js";
25
38
  import { extractTemplateRefs } from "../config/dsl-loader.js";
26
39
  import type { GitInfo, GitInfoOptions } from "../segments/git.js";
@@ -75,12 +88,96 @@ export interface EffectiveGlobals {
75
88
  // own comment.
76
89
  readonly presetCustomized: boolean;
77
90
  readonly style: StripStyle;
91
+ // [LAW:one-source-of-truth] The cell separator `plain` renders between
92
+ // segments (globals.default_separator). `string | undefined`, not a resolved
93
+ // string, precisely because its floor is NOT ours: PlainJoiner owns " | " and
94
+ // pickJoiner already reads undefined as "use the class default", so naming a
95
+ // floor here would be a second copy of a constant that lives in rich-js.
96
+ // Like charset it has no SessionState half — the config global (as staged by
97
+ // whatever fragment is on top) is its whole resolution.
98
+ readonly separator: string | undefined;
78
99
  readonly charset: Charset;
79
100
  readonly colorCompatibility: ColorCompatibility;
80
101
  readonly autoWrap: boolean;
81
102
  readonly padding: number;
82
103
  }
83
104
 
105
+ // [LAW:one-source-of-truth] THE resolution — one function, so the precedence
106
+ // chain has one implementation rather than one per caller. It previously stood
107
+ // as two structurally identical struct literals (the daemon's, in server.ts,
108
+ // and `cc-candybar check`'s), which is two clocks: the check command's job is
109
+ // to render what the daemon would render, and a rung added to one copy is a
110
+ // rung silently missing from the other. The callers differ only in WHERE a
111
+ // session value comes from and whether an overrides log exists to be customized
112
+ // by, so both arrive as parameters and nothing else forks
113
+ // [LAW:dataflow-not-control-flow].
114
+ //
115
+ // `sessionPick` is the reader for one SessionState key. `check` passes a
116
+ // function returning null for every key — a fresh session that has never
117
+ // clicked — rather than a null store, so "no session" travels as a VALUE
118
+ // through the same chain a real session travels [LAW:no-mode-explosion].
119
+ export function resolveEffectiveGlobals(
120
+ config: DslConfig,
121
+ sessionPick: (key: string) => string | null,
122
+ presetCustomized: (preset: string) => boolean,
123
+ ): EffectiveGlobals {
124
+ // The preset resolves FIRST: every field below reads globals, and which
125
+ // globals is exactly what the preset decides.
126
+ const preset = effectivePresetName(
127
+ sessionPick("preset"),
128
+ config.globals.preset,
129
+ config.presets,
130
+ );
131
+ const globals = presetGlobals(config, preset);
132
+ // [LAW:dataflow-not-control-flow] The staged fragment is a VALUE, and "edit
133
+ // mode is off" is the EMPTY value — the identity fragment, exactly as
134
+ // PRESET_FLOOR's is. Every field below is resolved by the same expression
135
+ // whether or not edit mode is on; only the contents of `staged` differ. This
136
+ // is the whole of "no render-walk branch on edit mode": there is no branch
137
+ // here either, so there is none to leak downstream.
138
+ const staged: Partial<Globals> =
139
+ sessionPick(EDIT_MODE_KEY) === EDIT_MODE_OPEN ? config.editGlobals : {};
140
+ return {
141
+ preset,
142
+ presetCustomized: presetCustomized(preset),
143
+ theme: effectiveThemeName(
144
+ staged.palette,
145
+ sessionPick("theme"),
146
+ globals.palette,
147
+ ),
148
+ look: effectiveLookName(
149
+ staged.look,
150
+ sessionPick("look"),
151
+ globals.look,
152
+ config.looks,
153
+ ),
154
+ style: effectiveStripStyle(
155
+ staged.style,
156
+ sessionPick("style"),
157
+ globals.style,
158
+ ),
159
+ // [LAW:one-source-of-truth] The fields with no SessionState half resolve as
160
+ // `staged ?? config ?? floor` — the same chain minus the rung they do not
161
+ // have, spelled with the same `??` rather than a second mechanism.
162
+ separator: staged.default_separator ?? globals.default_separator,
163
+ autoWrap: effectiveAutoWrap(
164
+ staged.autoWrap,
165
+ sessionPick("autoWrap"),
166
+ globals.autoWrap,
167
+ ),
168
+ padding: effectivePadding(
169
+ staged.padding,
170
+ sessionPick("padding"),
171
+ globals.padding,
172
+ ),
173
+ charset: staged.charset ?? globals.charset ?? DEFAULT_CHARSET,
174
+ colorCompatibility:
175
+ staged.colorCompatibility ??
176
+ globals.colorCompatibility ??
177
+ DEFAULT_COLOR_COMPATIBILITY,
178
+ };
179
+ }
180
+
84
181
  // ─── Augmented payload shape ─────────────────────────────────────────────────
85
182
 
86
183
  // [LAW:types-are-the-program] The RenderPayload extends ClaudeHookData with
@@ -63,20 +63,8 @@ import { buildDebugSnapshot } from "./debug";
63
63
  import { DEBUG_WHATS, isDebugWhat } from "./debug-types";
64
64
  import { expandHome } from "../config/dsl-loader.js";
65
65
  import { renderDsl } from "../dsl/render.js";
66
- import {
67
- effectiveStripStyle,
68
- effectiveAutoWrap,
69
- effectivePadding,
70
- effectiveThemeName,
71
- effectiveLookName,
72
- lookKeyByName,
73
- paletteForThemeName,
74
- } from "../themes/index.js";
75
- import {
76
- effectivePresetName,
77
- presetGlobals,
78
- presetIsCustomized,
79
- } from "../config/presets.js";
66
+ import { lookKeyByName, paletteForThemeName } from "../themes/index.js";
67
+ import { presetIsCustomized } from "../config/presets.js";
80
68
  import {
81
69
  renderStripCells,
82
70
  DEFAULT_CHARSET,
@@ -90,7 +78,11 @@ import {
90
78
  } from "../render/strip.js";
91
79
  import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
92
80
  import type { RichText } from "@promptctl/rich-js";
93
- import { buildRenderPayload, type EffectiveGlobals } from "./render-payload.js";
81
+ import {
82
+ buildRenderPayload,
83
+ resolveEffectiveGlobals,
84
+ type EffectiveGlobals,
85
+ } from "./render-payload.js";
94
86
  import { ContextProvider } from "../segments/context.js";
95
87
  import { MetricsProvider } from "../segments/metrics.js";
96
88
  import { TmuxService } from "../segments/tmux.js";
@@ -860,83 +852,28 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
860
852
  // No special-case branches — same composition every render.
861
853
  let body = "";
862
854
  if (entry.state !== null) {
863
- // [LAW:one-source-of-truth] Resolve the effective theme ONCE — the
864
- // session's chosen theme (SessionState) over the config default. This
865
- // single name drives BOTH the payload's `theme.effective` field (the
866
- // trigger label reads it) AND the rendered basePalette below, so a label
867
- // and the colors can never disagree. Resolved here, before the payload
868
- // build, so it can be threaded into the sole payload assembler.
869
- //
870
- // [LAW:one-source-of-truth] Every globals field a menu/stepper can
871
- // persist (candybar-config-engine-71o.3), resolved ONCE into one
872
- // struct: theme/look/style/autoWrap/padding compose the session's click
873
- // (SessionState) over the config default over a floor — a click
874
- // recolors/reshapes the whole bar on the next render; charset and
875
- // colorCompatibility describe the TERMINAL rather than a taste and so
876
- // have no SessionState half at all, making the config global over its
877
- // floor constant their whole resolution. This struct feeds
878
- // BOTH the payload's `*.effective` fields (trigger labels) AND
879
- // renderOpts below (the actual render) — one resolution, two readers,
880
- // so a label can never disagree with what was rendered.
881
- //
882
- // [LAW:one-source-of-truth] The PRESET resolves FIRST, because every
883
- // other field below reads globals — and which globals is exactly what
884
- // the preset decides. `presetGlobals` is the config's globals with the
885
- // active fragment's shallow-merged over them, so the preset sits at its
886
- // one documented place in the precedence chain (bundled default < user
887
- // file < persisted overrides < ACTIVE PRESET < session pick — see
888
- // src/config/presets.ts and docs/interaction-authoring.md): later than
889
- // everything read per cache ENTRY, earlier than every session click,
890
- // which is the order the `??` chains below already enforce by reading
891
- // SessionState first. There is no "does a preset apply?" branch — the
892
- // floor preset's empty fragment merges as a no-op
893
- // [LAW:dataflow-not-control-flow].
894
- const preset = effectivePresetName(
895
- sessionState.get(req.hookData.session_id, "preset"),
896
- entry.state.config.globals.preset,
897
- entry.state.config.presets,
898
- );
899
- const globals = presetGlobals(entry.state.config, preset);
900
- const effective: EffectiveGlobals = {
901
- preset,
855
+ // [LAW:one-source-of-truth] Every globals field resolved ONCE per
856
+ // render, here — before the payload build, so the same struct feeds
857
+ // BOTH the payload's `*.effective` fields (what a trigger label says)
858
+ // AND renderOpts below (what actually renders). One resolution, two
859
+ // readers, so a label can never disagree with the bar. The precedence
860
+ // the resolver applies, and why each rung sits where it does, lives
861
+ // with the chain (resolveEffectiveGlobals, and src/config/presets.ts).
862
+ // Read alongside the config, from the same entry, in one statement —
863
+ // which is exactly what the closure below claims about it.
864
+ const presetRootOps = entry.state.presetRootOps;
865
+ const effective: EffectiveGlobals = resolveEffectiveGlobals(
866
+ entry.state.config,
867
+ (key: string) => sessionState.get(req.hookData.session_id, key),
902
868
  // [LAW:one-source-of-truth] brandon-layout-edit-2gc.5 — read from
903
869
  // THIS entry's own presetRootOps (the record that fed the SAME
904
870
  // reload that produced entry.state.config), never a fresh
905
871
  // loadOverrides() here — a second read could race a concurrent
906
- // write and disagree with the tree that actually rendered.
907
- presetCustomized: presetIsCustomized(
908
- entry.state.presetRootOps,
909
- preset,
910
- ),
911
- theme: effectiveThemeName(
912
- sessionState.get(req.hookData.session_id, "theme"),
913
- globals.palette,
914
- ),
915
- look: effectiveLookName(
916
- sessionState.get(req.hookData.session_id, "look"),
917
- globals.look,
918
- entry.state.config.looks,
919
- ),
920
- style: effectiveStripStyle(
921
- sessionState.get(req.hookData.session_id, "style"),
922
- globals.style,
923
- ),
924
- autoWrap: effectiveAutoWrap(
925
- sessionState.get(req.hookData.session_id, "autoWrap"),
926
- globals.autoWrap,
927
- ),
928
- padding: effectivePadding(
929
- sessionState.get(req.hookData.session_id, "padding"),
930
- globals.padding,
931
- ),
932
- // charset and colorCompatibility have no session half by design —
933
- // they describe the terminal, not a taste (see CHARSETS in
934
- // themes/policy.ts) — so the config global over its floor is their
935
- // whole resolution.
936
- charset: globals.charset ?? DEFAULT_CHARSET,
937
- colorCompatibility:
938
- globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
939
- };
872
+ // write and disagree with the tree that actually rendered. That is
873
+ // why it arrives as a closure over this entry rather than being
874
+ // looked up inside the resolver.
875
+ (preset: string) => presetIsCustomized(presetRootOps, preset),
876
+ );
940
877
  const payload = await buildRenderPayload(
941
878
  req.hookData,
942
879
  payloadDeps,
@@ -956,6 +893,10 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
956
893
  // SAME `effective` struct the payload was just built from — no second
957
894
  // `?? DEFAULT_X` computation to drift from it.
958
895
  renderOpts.style = effective.style;
896
+ // The `plain` joiner's cell separator. Assigned unconditionally like
897
+ // every field around it: `undefined` is a value pickJoiner already
898
+ // reads as "PlainJoiner's own default", not an absence to branch on.
899
+ renderOpts.separator = effective.separator;
959
900
  renderOpts.wrap = effective.autoWrap;
960
901
  renderOpts.padding = effective.padding;
961
902
  renderOpts.charset = effective.charset;
package/src/demo/dsl.ts CHANGED
@@ -30,21 +30,10 @@ import { VariableStore } from "../var-system/store.js";
30
30
  import { SourceRegistry } from "../var-system/sources.js";
31
31
  import { SessionState } from "../daemon/session-state.js";
32
32
  import { listResolvablePaletteNames } from "../themes/policy.js";
33
- import {
34
- effectiveThemeName,
35
- effectiveLookName,
36
- effectiveAutoWrap,
37
- effectivePadding,
38
- lookKeyByName,
39
- paletteForThemeName,
40
- } from "../themes/index.js";
41
- import { effectivePresetName, presetGlobals } from "../config/presets.js";
33
+ import { lookKeyByName, paletteForThemeName } from "../themes/index.js";
34
+ import { resolveEffectiveGlobals } from "../daemon/render-payload.js";
42
35
  import { registerDslConfig, renderDsl } from "../dsl/render.js";
43
- import {
44
- DEFAULT_CHARSET,
45
- DEFAULT_COLOR_COMPATIBILITY,
46
- DEFAULT_TERMINAL_WIDTH,
47
- } from "../render/strip.js";
36
+ import { DEFAULT_TERMINAL_WIDTH } from "../render/strip.js";
48
37
  import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
49
38
 
50
39
  const FRAMES = 4;
@@ -83,18 +72,18 @@ const payload = {
83
72
  // globals every other option reads — the same preset-first order server.ts and
84
73
  // check.ts resolve in, so the demo prints the arrangement a fresh session opens
85
74
  // in.
86
- const preset = effectivePresetName(null, config.globals.preset, config.presets);
87
- const globals = presetGlobals(config, preset);
88
- const basePalette = paletteForThemeName(
89
- effectiveThemeName(null, globals.palette),
90
- );
91
- // Same fresh-session resolution one dimension over: the config-default look
92
- // over the "none" identity floor — the exact mirror of the daemon's per-render
93
- // effectiveLookName → lookKeyByName chain.
94
- const lookKey = lookKeyByName(
95
- config.looks,
96
- effectiveLookName(null, globals.look, config.looks),
75
+ // [LAW:one-source-of-truth] THE daemon's resolver, not a mirror of it — a
76
+ // fresh-session pick reader (null for every key) and no overrides log to be
77
+ // customized by. The demo previously restated this chain field by field and had
78
+ // already drifted: it hardcoded `style: "powerline"` below and so ignored a
79
+ // config's own `globals.style`.
80
+ const effective = resolveEffectiveGlobals(
81
+ config,
82
+ () => null,
83
+ () => false,
97
84
  );
85
+ const basePalette = paletteForThemeName(effective.theme);
86
+ const lookKey = lookKeyByName(config.looks, effective.look);
98
87
 
99
88
  // A fresh store + registry for this run. (A hot-reloading daemon would
100
89
  // dispose() the old pair and build new ones — see registerDslConfig's docs.)
@@ -128,26 +117,21 @@ try {
128
117
  payload,
129
118
  basePalette,
130
119
  {
131
- style: "powerline",
132
- // Same resolution the daemon applies: the config global over the
133
- // truecolor default floor.
134
- colorCompatibility:
135
- globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
120
+ style: effective.style,
121
+ separator: effective.separator,
122
+ colorCompatibility: effective.colorCompatibility,
136
123
  // [LAW:one-source-of-truth] Demo applies the same Claude-Code-UI
137
124
  // reserve the daemon does so demo output matches the bytes a real
138
125
  // statusline would emit at the same terminal width.
139
126
  width: applyClaudeCodeReserve(
140
127
  process.stdout.columns ?? DEFAULT_TERMINAL_WIDTH,
141
128
  ),
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),
147
- charset: globals.charset ?? DEFAULT_CHARSET,
129
+ wrap: effective.autoWrap,
130
+ padding: effective.padding,
131
+ charset: effective.charset,
148
132
  },
149
133
  undefined,
150
- { look: lookKey, preset },
134
+ { look: lookKey, preset: effective.preset },
151
135
  );
152
136
  process.stdout.write(` ${line}\n`);
153
137
  if (frame < FRAMES - 1) await sleep(FRAME_INTERVAL_MS);
@@ -32,6 +32,7 @@ import {
32
32
  type ActionDecl,
33
33
  } from "../config/action.js";
34
34
  import { resolveOptionDomain } from "../config/option-domain.js";
35
+ import { pickCycleDisplay } from "../config/disclosure.js";
35
36
  import { encodeLayoutOp, type LayoutOp } from "../config/layout-ops.js";
36
37
  import { parseSessionBoolean, type StripStyle } from "../themes/policy.js";
37
38
  import {
@@ -800,15 +801,16 @@ function selectDisplay(
800
801
  throw new Error(`action "${name}" needs a display (the clickable text)`);
801
802
  }
802
803
  if (action.kind === "set-cycle" || action.kind === "persist-cycle") {
803
- if (displays.length !== 1 && displays.length !== action.members.length) {
804
- throw new Error(
805
- `action "${name}" cycles ${action.members.length} members; bind one display per member (${action.members.length}) or one static display, got ${displays.length}`,
806
- );
807
- }
808
- const display =
809
- displays.length === 1
810
- ? displays[0]!
811
- : displays[cycleIndex(action, store)]!;
804
+ // [LAW:single-enforcer] The arity rule and the pick are the disclosure
805
+ // primitive's, not this file's — `{{ menu }}` resolves its own trigger
806
+ // through the same function over its `[closed, member]` cycle, so the two
807
+ // disclosure kinds cannot disagree about what a display binding means.
808
+ const display = pickCycleDisplay(
809
+ `action "${name}"`,
810
+ displays,
811
+ action.members.length,
812
+ cycleIndex(action, store),
813
+ );
812
814
  return { display, boundValue: undefined };
813
815
  }
814
816
  if (displays.length > 2) {