@promptctl/cc-candybar 1.39.0 → 1.41.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.
@@ -49,8 +49,13 @@ import {
49
49
  DISCLOSURE_GLYPH_CLOSED,
50
50
  DISCLOSURE_GLYPH_OPEN,
51
51
  disclosureCycleAction,
52
+ disclosureGate,
52
53
  disclosureStateVar,
54
+ disclosureTrigger,
55
+ type DisclosureRef,
53
56
  } from "./disclosure.js";
57
+ import { declareHelp, type HelpDisclosure } from "./help.js";
58
+ import { PERSIST_HELP } from "../help-text.js";
54
59
  import {
55
60
  EDIT_MODE_KEY,
56
61
  EDIT_MODE_OPEN,
@@ -127,11 +132,34 @@ const CONFIG_SEG = `${SETTINGS_NS}config`;
127
132
  // cannot silently write a durable default today — SessionState is per session.
128
133
  const PERSIST_KEY = PERSIST_SEG;
129
134
 
130
- // [LAW:one-source-of-truth] The config menu's own disclosure, spelled the same
131
- // way the anchor above is: ONE name that is the segment, the state variable and
132
- // the cycle action, so its toggle's click and its body's `when` cannot address
133
- // different keys.
134
- const CONFIG_OPEN_GATE = `{{ and (eq .${SETTINGS_ANCHOR} "${SETTINGS_OPEN}") (eq .${CONFIG_SEG} "${SETTINGS_OPEN}") }}`;
135
+ // [LAW:one-source-of-truth] The two disclosures this menu IS, as refs rather
136
+ // than as gate strings: every gate below — and every `(?)` nested inside them —
137
+ // derives from these, so the toggle that writes a key and the `when` that reads
138
+ // it cannot name different variables.
139
+ const SETTINGS_REF: DisclosureRef = {
140
+ variable: SETTINGS_ANCHOR,
141
+ member: SETTINGS_OPEN,
142
+ };
143
+ const CONFIG_REF: DisclosureRef = {
144
+ variable: CONFIG_SEG,
145
+ member: SETTINGS_OPEN,
146
+ };
147
+
148
+ // Gated on BOTH keys — a config row left open yesterday must not render beside
149
+ // a closed menu today. Nesting is conjunction, which is why it is one list.
150
+ const CONFIG_OPEN_GATE = disclosureGate(SETTINGS_REF, CONFIG_REF);
151
+
152
+ // The `(?)` that explains `persist?` — the one control in this menu whose
153
+ // behaviour a user cannot infer from its label, which is exactly why the ticket
154
+ // named it as a required use site. Its body says what the NEXT click does, in
155
+ // the same two sentences `--help` prints.
156
+ const PERSIST_HELP_SEG = `${SETTINGS_NS}help.persist`;
157
+
158
+ // [LAW:one-source-of-truth] The panel's surface colours, spelled once. The help
159
+ // cells must wear the same ones as the controls they explain — a `(?)` body in
160
+ // a different colour reads as a different panel — and that agreement is only
161
+ // guaranteed if there is one value to hand both.
162
+ const SETTINGS_SURFACE = { bg: "surface", fg: "foreground" } as const;
135
163
 
136
164
  // [LAW:one-source-of-truth] One accordion key for every picker in the menu:
137
165
  // one key holds one open member, so opening a theme picker closes the look
@@ -278,7 +306,7 @@ const controlReset = (name: string): string => `${SETTINGS_NS}reset.${name}`;
278
306
  // from the same anchor string the toggle's cycle writes — spelled once here,
279
307
  // exactly as lowerGroup derives a group body's `when` from the group's own
280
308
  // reference name.
281
- const SETTINGS_OPEN_GATE = `{{ eq .${SETTINGS_ANCHOR} "${SETTINGS_OPEN}" }}`;
309
+ const SETTINGS_OPEN_GATE = disclosureGate(SETTINGS_REF);
282
310
 
283
311
  // [LAW:single-enforcer] The one answer to "is this segment reference the global
284
312
  // menu's anchor". cross-ref.ts asks it to accept an authored placement of a name
@@ -379,7 +407,10 @@ export function countAnchors(node: LayoutNode): number {
379
407
  // a vertical pair of the toggle segment and a `when`-gated body. Replaces the
380
408
  // anchor leaf wherever it sits, so the author's chosen position is the menu's
381
409
  // position with nothing else moved.
382
- function expandAnchor(node: AnchoredRoot | LayoutNode): LayoutNode {
410
+ function expandAnchor(
411
+ node: AnchoredRoot | LayoutNode,
412
+ help: HelpDisclosure,
413
+ ): LayoutNode {
383
414
  if (node.kind === "segment") {
384
415
  return isSettingsAnchor(node.name)
385
416
  ? {
@@ -395,6 +426,32 @@ function expandAnchor(node: AnchoredRoot | LayoutNode): LayoutNode {
395
426
  direction: "horizontal",
396
427
  children: [
397
428
  { kind: "segment", name: PERSIST_SEG },
429
+ // The `(?)` rides the row that already exists, immediately
430
+ // after the control it explains — so closed help costs no row
431
+ // and widens the bar by one cell, and open help reads as an
432
+ // answer to the checkbox on its left.
433
+ //
434
+ // Mid-row, DELIBERATELY, unlike edit mode's `(?)`, which
435
+ // edit-chrome.ts goes to lengths to trail. The difference is
436
+ // structural, not a discipline applied in one file and skipped
437
+ // here. `nextHueShift` (src/dsl/render.ts:697) counts segment
438
+ // leaves in pre-order, so a leaf's hue index is the number of
439
+ // leaves before it — which makes the consequence arithmetic:
440
+ // reordering leaves WITHIN a subtree cannot change the index of
441
+ // any leaf AFTER it, since the subtree's leaf count does not
442
+ // move. Edit chrome WRAPS the whole tree, so trailing there is
443
+ // after every existing leaf and costs zero. This menu splices
444
+ // MID-TREE at an anchor `withAnchor` lets the author put
445
+ // anywhere, so no position inside it is after the rest of the
446
+ // bar: the leaves it adds — this trigger plus one per
447
+ // PERSIST_HELP line, a count that lives in help-text.ts and is
448
+ // deliberately not copied here — shift everything past the
449
+ // anchor wherever inside the menu they sit. Trailing would cost
450
+ // the adjacency that IS the affordance. The fix is decoupling
451
+ // colour from tree position — candybar-render-y5h, which fixes
452
+ // every mid-tree synthesis at once rather than one file at a
453
+ // time.
454
+ help.trigger,
398
455
  ...PRIMARY_CONTROLS.map(
399
456
  (c): LayoutNode => ({
400
457
  kind: "segment",
@@ -406,6 +463,9 @@ function expandAnchor(node: AnchoredRoot | LayoutNode): LayoutNode {
406
463
  ],
407
464
  when: SETTINGS_OPEN_GATE,
408
465
  },
466
+ // The help body: one row, present only while the `(?)` is open,
467
+ // directly under the row that asked the question.
468
+ help.body,
409
469
  // Row two: the display settings, behind their own disclosure so
410
470
  // the menu opens narrow. Gated on BOTH keys — a config row left
411
471
  // open yesterday must not render beside a closed menu today; one
@@ -429,7 +489,10 @@ function expandAnchor(node: AnchoredRoot | LayoutNode): LayoutNode {
429
489
  }
430
490
  : node;
431
491
  }
432
- return { ...node, children: node.children.map(expandAnchor) };
492
+ return {
493
+ ...node,
494
+ children: node.children.map((child) => expandAnchor(child, help)),
495
+ };
433
496
  }
434
497
 
435
498
  // ─── The artifacts ──────────────────────────────────────────────────────────
@@ -476,7 +539,10 @@ function declareHostedMenu(
476
539
  // reference, and a second reference to one declaration is a reuse, not the
477
540
  // self-collision a second `kind: "group"` node would be (see the settingsDrawer
478
541
  // comment in default-dsl-config.ts for that hazard in its original form).
479
- function settingsArtifacts(): MenuArtifacts {
542
+ function settingsArtifacts(): {
543
+ artifacts: MenuArtifacts;
544
+ help: HelpDisclosure;
545
+ } {
480
546
  const artifacts: MenuArtifacts = {
481
547
  variables: {
482
548
  [SETTINGS_ANCHOR]: disclosureStateVar(SETTINGS_ANCHOR, DISCLOSURE_CLOSED),
@@ -497,22 +563,27 @@ function settingsArtifacts(): MenuArtifacts {
497
563
  // [LAW:representation] The glyph trails the label it gates, per the
498
564
  // disclosure vocabulary every other toggle in the bar reads by.
499
565
  [SETTINGS_ANCHOR]: {
500
- template: `{{ action "${SETTINGS_ANCHOR}" "☰ ${DISCLOSURE_GLYPH_CLOSED}" "☰ ${DISCLOSURE_GLYPH_OPEN}" }}`,
501
- bg: "surface",
502
- fg: "foreground",
566
+ template: disclosureTrigger(
567
+ SETTINGS_ANCHOR,
568
+ `☰ ${DISCLOSURE_GLYPH_CLOSED}`,
569
+ `☰ ${DISCLOSURE_GLYPH_OPEN}`,
570
+ ),
571
+ ...SETTINGS_SURFACE,
503
572
  },
504
573
  // [LAW:representation] The checkbox states what the NEXT write does,
505
574
  // which is why the glyph and the word live together: "☑ persist?" is
506
575
  // the whole explanation of where the click below it lands.
507
576
  [PERSIST_SEG]: {
508
577
  template: `{{ action "${PERSIST_SEG}" "☐ persist?" "☑ persist?" }}`,
509
- bg: "surface",
510
- fg: "foreground",
578
+ ...SETTINGS_SURFACE,
511
579
  },
512
580
  [CONFIG_SEG]: {
513
- template: `{{ action "${CONFIG_SEG}" "⚙ config ${DISCLOSURE_GLYPH_CLOSED}" "⚙ config ${DISCLOSURE_GLYPH_OPEN}" }}`,
514
- bg: "surface",
515
- fg: "foreground",
581
+ template: disclosureTrigger(
582
+ CONFIG_SEG,
583
+ `⚙ config ${DISCLOSURE_GLYPH_CLOSED}`,
584
+ `⚙ config ${DISCLOSURE_GLYPH_OPEN}`,
585
+ ),
586
+ ...SETTINGS_SURFACE,
516
587
  },
517
588
  // [LAW:one-type-per-behavior] Both non-picker controls read the same
518
589
  // `.effective` projection their picker siblings read, and write the
@@ -522,8 +593,7 @@ function settingsArtifacts(): MenuArtifacts {
522
593
  template:
523
594
  `{{ action "${controlApply("wrap")}" "wrap: on" "wrap: off" }} ` +
524
595
  `{{ action "${controlReset("wrap")}" "↺" }}`,
525
- bg: "surface",
526
- fg: "foreground",
596
+ ...SETTINGS_SURFACE,
527
597
  },
528
598
  [PADDING_SEG]: {
529
599
  template:
@@ -531,8 +601,7 @@ function settingsArtifacts(): MenuArtifacts {
531
601
  "padding {{ .padding.effective }} " +
532
602
  `{{ action "${controlApply("padding")}.up" "▶" }} ` +
533
603
  `{{ action "${controlReset("padding")}" "↺" }}`,
534
- bg: "surface",
535
- fg: "foreground",
604
+ ...SETTINGS_SURFACE,
536
605
  },
537
606
  // The entry point edit mode never had: `edit.toggle` is a reserved action
538
607
  // whose only bundled reference lives in the `toolbar` segment, which a
@@ -540,8 +609,7 @@ function settingsArtifacts(): MenuArtifacts {
540
609
  // from a segment no config can drop.
541
610
  [EDIT_SEG]: {
542
611
  template: `{{ action "${EDIT_TOGGLE_ACTION}" "✎ edit" "✎ done" }}`,
543
- bg: "surface",
544
- fg: "foreground",
612
+ ...SETTINGS_SURFACE,
545
613
  },
546
614
  },
547
615
  };
@@ -555,7 +623,19 @@ function settingsArtifacts(): MenuArtifacts {
555
623
  DISCLOSURE_CLOSED,
556
624
  );
557
625
  declareSettingControls(artifacts);
558
- return artifacts;
626
+ // [LAW:one-source-of-truth] The `(?)` is minted here, with the panel it
627
+ // belongs to, and its two NODES are returned so `expandAnchor` places them by
628
+ // the value it is handed rather than by re-deriving names this pass already
629
+ // owns. Nested in SETTINGS_REF, so closing the menu takes the open help with
630
+ // it.
631
+ const help = declareHelp(
632
+ PERSIST_HELP_SEG,
633
+ PERSIST_HELP,
634
+ [SETTINGS_REF],
635
+ artifacts,
636
+ SETTINGS_SURFACE,
637
+ );
638
+ return { artifacts, help };
559
639
  }
560
640
 
561
641
  // [LAW:one-source-of-truth] Every setting the menu offers, minted from the one
@@ -581,8 +661,7 @@ function declareSettingControls(artifacts: MenuArtifacts): void {
581
661
  `{{ menu "${apply}" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" ` +
582
662
  `(dict "key" "${PICKER_KEY}" "closeOnPick" true) }} ` +
583
663
  `{{ action "${controlReset(c.name)}" "↺" }}`,
584
- bg: "surface",
585
- fg: "foreground",
664
+ ...SETTINGS_SURFACE,
586
665
  };
587
666
  artifacts.actions[apply] = {
588
667
  set: c.sessionKey,
@@ -676,14 +755,14 @@ export function canHostSessionState(config: DslConfig): boolean {
676
755
  // and every name now declares one.
677
756
  export function synthesizeSettingsMenu(config: DslConfig): DslConfig {
678
757
  if (!canHostSessionState(config)) return config;
679
- const artifacts = settingsArtifacts();
758
+ const { artifacts, help } = settingsArtifacts();
680
759
  ensureEditToggle(artifacts);
681
760
  const presets: Record<string, PresetDecl> = { ...config.presets };
682
761
  for (const name of presetNames(config.presets)) {
683
762
  const { node } = presetRoot(config, name);
684
763
  presets[name] = {
685
764
  ...presetByName(config.presets, name),
686
- root: expandAnchor(withAnchor(node)),
765
+ root: expandAnchor(withAnchor(node), help),
687
766
  };
688
767
  }
689
768
  return {
@@ -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);
package/src/help-text.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { DISCLOSURE_GLYPH_CLOSED } from "./config/disclosure";
2
+ import { HELP_GLYPH_CLOSED } from "./config/help";
2
3
 
3
4
  // [LAW:effects-at-boundaries] Pure data, no I/O — index.ts owns the console.log
4
5
  // effect. Kept as its own module so the text is importable (and testable) without
@@ -6,6 +7,29 @@ import { DISCLOSURE_GLYPH_CLOSED } from "./config/disclosure";
6
7
  // [LAW:one-source-of-truth] The disclosure glyph comes from config/disclosure.ts
7
8
  // (the same constant the theme/look picker itself renders with), so this text
8
9
  // can't drift from what a user actually sees on the bar.
10
+ // [LAW:one-source-of-truth] THE help corpus, as data. `--help` and the bar's
11
+ // own `(?)` disclosures are two RENDERINGS of these arrays, never two copies of
12
+ // the sentences: a `(?)` segment's template IS one of these strings, and the
13
+ // paragraphs below interpolate the same values. A help sentence typed into a
14
+ // segment template — where nothing would ever notice it drifting from the CLI's
15
+ // wording — is the defect this shape exists to make unrepresentable.
16
+ //
17
+ // [LAW:representation] One line is one CELL on the bar, so each is a complete
18
+ // thought that stands alone and each stays short: `(?)` bodies drop below their
19
+ // row, and a body that overflows `term.cols` wraps into more rows than the fact
20
+ // it explains is worth. Every line leads with the glyph it explains, so the
21
+ // reader matches text to affordance by shape rather than by reading order.
22
+ export const EDIT_MODE_HELP = [
23
+ "+ inserts here",
24
+ "- removes the one left of it",
25
+ "↺ undoes edits",
26
+ ] as const;
27
+
28
+ export const PERSIST_HELP = [
29
+ "☐ this session only",
30
+ "☑ default for every session",
31
+ ] as const;
32
+
9
33
  export const HELP_TEXT = `
10
34
  cc-candybar - Beautiful powerline statusline for Claude Code
11
35
 
@@ -27,8 +51,10 @@ Configuration:
27
51
  needed, and writing your own \`root\` cannot delete it. Click
28
52
  ☰ ${DISCLOSURE_GLYPH_CLOSED} on the bar for preset switching, edit mode, and a config menu
29
53
  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.
54
+ checkbox there chooses where a change lands: ${PERSIST_HELP.join(", ")}.
55
+
56
+ Anywhere the bar shows ${HELP_GLYPH_CLOSED}, clicking it reveals these same instructions
57
+ in place. In edit mode: ${EDIT_MODE_HELP.join(", ")}.
32
58
 
33
59
  Subcommands:
34
60
  install One-shot setup: stages the runtime (native render