@promptctl/cc-candybar 1.39.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.
@@ -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);
@@ -52,14 +52,30 @@ export function resolvePaletteName(name: string): string {
52
52
  // default rather than throw or render something the label disagrees with
53
53
  // [LAW:no-silent-failure] — the caller publishes what this returns as
54
54
  // `<field>.effective`, so bar and label always trace to one value.
55
+ //
56
+ // [LAW:one-source-of-truth] `staged` is the RIGHTMOST rung of the precedence
57
+ // chain documented in src/config/presets.ts — a fragment some transient MODE of
58
+ // the bar puts on top while it is on (today: edit mode's `editGlobals`). It
59
+ // outranks even the session pick because it is decided LATER: a user picks a
60
+ // style, then afterwards enters edit mode. That is the same lifetime rule that
61
+ // forced the preset's own position, applied one rung further along, which is
62
+ // why it is a parameter of THIS function rather than a check at any call site —
63
+ // a chain with a rung missing from one field is exactly the drift a single
64
+ // resolver exists to prevent.
65
+ //
66
+ // [LAW:dataflow-not-control-flow] Absent ⇒ `undefined`, which the `??` chain
67
+ // skips by the same code path a session that never clicked skips its own rung.
68
+ // There is no "is edit mode on" branch anywhere below this line; the mode is
69
+ // carried entirely by whether this argument has a value.
55
70
  export function effectiveGlobal<T>(
71
+ staged: T | null | undefined,
56
72
  sessionPick: string | null,
57
73
  configDefault: T | null | undefined,
58
74
  floor: T,
59
75
  parseSession: (raw: string) => T | null,
60
76
  ): T {
61
77
  const picked = sessionPick === null ? null : parseSession(sessionPick);
62
- return picked ?? configDefault ?? floor;
78
+ return staged ?? picked ?? configDefault ?? floor;
63
79
  }
64
80
 
65
81
  // The theme name a render should use, as data.
@@ -70,10 +86,12 @@ export function effectiveGlobal<T>(
70
86
  // parse is identity: there is no membership to check here, and pretending
71
87
  // otherwise would collapse names `paletteForThemeName` handles fine.
72
88
  export function effectiveThemeName(
89
+ stagedPalette: string | undefined,
73
90
  sessionTheme: string | null,
74
91
  globalsPalette: string | undefined,
75
92
  ): string {
76
93
  return effectiveGlobal(
94
+ stagedPalette,
77
95
  sessionTheme,
78
96
  globalsPalette,
79
97
  "textual-dark",
@@ -110,7 +128,12 @@ export function listResolvablePaletteNames(): readonly string[] {
110
128
  //
111
129
  // The floor's membership is a load-time guarantee, not a runtime hope: the
112
130
  // bundled stdlib ships it and merge-by-name cannot remove it.
131
+ //
132
+ // The staged rung runs through the SAME membership parse the other two do: a
133
+ // fragment naming a member the config does not declare is no more a pick than a
134
+ // stale session entry is, and collapses one rung onward rather than throwing.
113
135
  export function effectiveMemberName(
136
+ stagedName: string | undefined,
114
137
  sessionPick: string | null,
115
138
  configDefault: string | undefined,
116
139
  floor: string,
@@ -119,6 +142,7 @@ export function effectiveMemberName(
119
142
  const member = (raw: string): string | null =>
120
143
  Object.prototype.hasOwnProperty.call(declared, raw) ? raw : null;
121
144
  return effectiveGlobal(
145
+ stagedName === undefined ? null : member(stagedName),
122
146
  sessionPick,
123
147
  member(configDefault ?? floor),
124
148
  floor,
@@ -133,11 +157,18 @@ export function effectiveMemberName(
133
157
  // carries. A named wrapper (not a bare call at each site) so the floor is
134
158
  // spelled once and the three call sites cannot disagree about it.
135
159
  export function effectiveLookName(
160
+ stagedLook: string | undefined,
136
161
  sessionLook: string | null,
137
162
  globalsLook: string | undefined,
138
163
  declaredLooks: Readonly<Record<string, ThemeKey>>,
139
164
  ): string {
140
- return effectiveMemberName(sessionLook, globalsLook, "none", declaredLooks);
165
+ return effectiveMemberName(
166
+ stagedLook,
167
+ sessionLook,
168
+ globalsLook,
169
+ "none",
170
+ declaredLooks,
171
+ );
141
172
  }
142
173
 
143
174
  // [LAW:single-enforcer] The one place an effective look NAME becomes the
@@ -196,11 +227,16 @@ export function isStripStyle(value: string): value is StripStyle {
196
227
  // [LAW:one-source-of-truth]; test/session-globals.test.ts pins it with a stale
197
228
  // pick over a valid non-floor default, the case the old tests never exercised.
198
229
  export function effectiveStripStyle(
230
+ stagedStyle: StripStyle | undefined,
199
231
  sessionStyle: string | null,
200
232
  globalsStyle: StripStyle | undefined,
201
233
  ): StripStyle {
202
- return effectiveGlobal(sessionStyle, globalsStyle, "powerline", (raw) =>
203
- isStripStyle(raw) ? raw : null,
234
+ return effectiveGlobal(
235
+ stagedStyle,
236
+ sessionStyle,
237
+ globalsStyle,
238
+ "powerline",
239
+ (raw) => (isStripStyle(raw) ? raw : null),
204
240
  );
205
241
  }
206
242
 
@@ -323,10 +359,12 @@ function parsePadding(raw: string): number | null {
323
359
  // Whether a render wraps over-wide rows, as data. The session's pick over the
324
360
  // config default over the on floor — `effectiveGlobal` with a boolean domain.
325
361
  export function effectiveAutoWrap(
362
+ stagedAutoWrap: boolean | undefined,
326
363
  sessionAutoWrap: string | null,
327
364
  globalsAutoWrap: boolean | undefined,
328
365
  ): boolean {
329
366
  return effectiveGlobal(
367
+ stagedAutoWrap,
330
368
  sessionAutoWrap,
331
369
  globalsAutoWrap,
332
370
  DEFAULT_WRAP,
@@ -341,10 +379,12 @@ export function effectiveAutoWrap(
341
379
  // own default is the honest answer rather than a render at a width nobody
342
380
  // chose.
343
381
  export function effectivePadding(
382
+ stagedPadding: number | undefined,
344
383
  sessionPadding: string | null,
345
384
  globalsPadding: number | undefined,
346
385
  ): number {
347
386
  return effectiveGlobal(
387
+ stagedPadding,
348
388
  sessionPadding,
349
389
  globalsPadding,
350
390
  DEFAULT_PADDING,