@promptctl/cc-candybar 1.42.1 → 1.43.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.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
package/src/dsl/render.ts DELETED
@@ -1,803 +0,0 @@
1
- // [LAW:single-enforcer] registerDslConfig + renderDsl are THE two spine
2
- // functions the daemon calls verbatim. No parallel registration path, no
3
- // alternate render path. bzh.2 reuses these; it does not reimplement them.
4
- //
5
- // [LAW:one-source-of-truth] registerDslConfig is the single JSON-shape →
6
- // runtime translation. Every VariableDecl kind maps to exactly one
7
- // SourceRegistry.declare* call here, and template pre-compilation happens
8
- // exactly once (at registration, not per render).
9
- //
10
- // [LAW:dataflow-not-control-flow] Both functions execute unconditionally;
11
- // the input values (kind discriminators, layout length, palette presence)
12
- // govern output, not whether operations run.
13
-
14
- import type { RichText, Palette, ThemeKey } from "@promptctl/rich-js";
15
- import { ColorSpec, Style, lighten, IDENTITY } from "@promptctl/rich-js";
16
- import { Defines, type Engine, type Template } from "@promptctl/go-template-js";
17
- import type {
18
- ValidatedConfig,
19
- VariableDecl,
20
- CacheDecl,
21
- LayoutNode,
22
- } from "../config/dsl-types.js";
23
- import { HUE_STEP_VAR } from "../config/dsl-types.js";
24
- import { perConfigDomainsFor } from "../config/option-domain.js";
25
- import { PRESET_FLOOR, presetNames, presetRoot } from "../config/presets.js";
26
- import { addableSegmentDomains } from "../config/edit-chrome.js";
27
- import type { VariableStore } from "../var-system/store.js";
28
- import type { SourceRegistry } from "../var-system/sources.js";
29
- import {
30
- parseDuration,
31
- type CachePolicy,
32
- type GitField,
33
- } from "../var-system/sources.js";
34
- import type { BuildLineOptions } from "../render/strip.js";
35
- import { DEFAULT_PADDING, renderStripCells } from "../render/strip.js";
36
- import { paletteForThemeName } from "../themes/index.js";
37
- import { buildScope } from "../template-engine/scope.js";
38
- import {
39
- createCcCandybarEngine,
40
- evaluateWhen,
41
- resolveSegmentColors,
42
- } from "../template-engine/index.js";
43
- import {
44
- compileActions,
45
- actionFuncs,
46
- type ActionRuntime,
47
- } from "../render/action.js";
48
- import { pickerFuncs } from "../render/picker.js";
49
- import {
50
- menuFuncs,
51
- collectMenuDrops,
52
- type MenuRuntime,
53
- } from "../render/menu.js";
54
- import {
55
- createActiveSegmentRef,
56
- type ActiveSegmentRef,
57
- } from "../render/active-segment.js";
58
- import { segmentColorFuncs } from "../render/segment-color.js";
59
- // [LAW:one-way-deps] The node-type registry sits below this driver: it owns the
60
- // compiled node shapes + each kind's compile/render, dispatched via nodeType().
61
- // render.ts threads the recursion (compileChild/renderChild) + the hue counter in
62
- // as capabilities; it never re-switches on node kind.
63
- import {
64
- nodeType,
65
- type CompiledNode,
66
- type CompiledSegment,
67
- type CompiledSegments,
68
- type RenderedLines,
69
- type NodeCompileCtx,
70
- type NodeRenderCtx,
71
- } from "./node-registry.js";
72
-
73
- // ─── Compiled config ───────────────────────────────────────────────────────────
74
-
75
- // [LAW:one-source-of-truth] The full compiled artifact registerDslConfig
76
- // produces: every segment's compiled templates AND the compiled layout tree
77
- // (nodes with parsed `when`). renderDsl needs both; bundling them keeps the
78
- // daemon cache holding one value, not two that could fall out of sync. The
79
- // compiled node + segment shapes live in node-registry (the render layer that
80
- // owns node behavior); this driver only assembles + walks them.
81
- export interface CompiledConfig {
82
- readonly segments: CompiledSegments;
83
- // [LAW:dataflow-not-control-flow] EVERY preset's layout, compiled up front and
84
- // keyed by preset name — the render selects one by name rather than compiling
85
- // per session. This is the same move `looks` makes one level down (every
86
- // look's ThemeKey is resolved at load; the render picks one), and it is what
87
- // lets a per-SESSION preset pick ride a per-ENTRY compilation: one RenderCache
88
- // entry serves many sessions, so nothing session-shaped may be compiled here.
89
- // Total over `presetNames` — every selectable name, floor included — so the
90
- // lookup needs no absent case. A preset declaring no `root` of its own maps
91
- // to the config's own compiled root: the identity element, not a special
92
- // case.
93
- readonly roots: ReadonlyMap<string, CompiledNode>;
94
- // [LAW:locality-or-seam] The menu runtime the engine's `menu` func closes over.
95
- readonly menuRuntime: MenuRuntime;
96
- // [LAW:one-source-of-truth] The single "which segment is rendering" record
97
- // every segment-scoped template function reads — the menu's identity, the
98
- // `color` func's palette, the `bgOf` func's background. Surfaced here so the
99
- // walk can publish into it. One instance per compiled config; mutated
100
- // synchronously within a single renderDsl walk (renders are sequential +
101
- // synchronous, so no cross-render leak) — the spatial cousin of the hue
102
- // cursor, one owner. [LAW:no-ambient-temporal-coupling]
103
- readonly activeSegment: ActiveSegmentRef;
104
- // [LAW:types-are-the-program] Variable declaration failures that did NOT
105
- // prevent the config from loading (type mismatches, bad defaults). The
106
- // affected variables are absent from the store; segments that reference them
107
- // render as error cells. Non-empty means "partial load" — the config is
108
- // usable but degraded.
109
- readonly loadWarnings: readonly string[];
110
- }
111
-
112
- // ─── CacheDecl → CachePolicy ─────────────────────────────────────────────────
113
-
114
- // [LAW:dataflow-not-control-flow] One arm per CacheDecl variant; the in-check
115
- // is the discriminator, not control flow. Adding a new variant requires one
116
- // new arm here and a matching CacheDecl arm in dsl-types.
117
- function toCachePolicy(cache: CacheDecl): CachePolicy {
118
- if ("ttl" in cache)
119
- return { kind: "ttl", durationMs: parseDuration(cache.ttl) };
120
- if ("watch_file" in cache)
121
- return { kind: "watch_file", path: cache.watch_file };
122
- if ("depends_on" in cache)
123
- return { kind: "depends_on", varNames: cache.depends_on };
124
- if ("key" in cache) return { kind: "key", template: cache.key };
125
- if ("never" in cache) return { kind: "never" };
126
- throw new Error(
127
- `Unknown CacheDecl discriminator — loader invariant violated: ${JSON.stringify(cache)}`,
128
- );
129
- }
130
-
131
- // ─── Single variable declaration ──────────────────────────────────────────────
132
-
133
- // [LAW:single-enforcer] One function dispatches every VariableDecl kind to its
134
- // SourceRegistry method. No other code path declares variables.
135
- function declareOne(
136
- registry: SourceRegistry,
137
- name: string,
138
- decl: VariableDecl,
139
- cwd: string,
140
- ): void {
141
- switch (decl.kind) {
142
- case "literal":
143
- registry.declareLiteral(name, decl.value as string | number | boolean);
144
- break;
145
-
146
- case "input":
147
- // [LAW:types-are-the-program] The loader validated that `decl.type` is
148
- // one of "string"|"number"|"boolean" and that `decl.default` (if
149
- // present) matches that type. Absent type defaults to "string" — every
150
- // existing declaration that omits the field reads a string at the
151
- // resolved payload path.
152
- registry.declareInput(
153
- name,
154
- decl.path,
155
- decl.type ?? "string",
156
- decl.default,
157
- );
158
- break;
159
-
160
- case "env":
161
- registry.declareEnv(name, decl.name, decl.default);
162
- break;
163
-
164
- case "file":
165
- registry.declareFile(name, decl.path, {
166
- readMode: decl.readMode,
167
- regex: decl.regex,
168
- cache: toCachePolicy(decl.cache),
169
- varDefault: decl.default,
170
- });
171
- break;
172
-
173
- case "shell":
174
- registry.declareShell(name, decl.command, {
175
- regex: decl.regex,
176
- cache: toCachePolicy(decl.cache),
177
- varDefault: decl.default,
178
- });
179
- break;
180
-
181
- case "template":
182
- registry.declareTemplate(name, decl.template, {
183
- varDefault: decl.default,
184
- });
185
- break;
186
-
187
- case "time":
188
- // [LAW:types-are-the-program] TimeVarDecl.cache is ttl-only by
189
- // construction — the loader rejects every other CacheDecl form at load
190
- // (the runtime honors no other invalidation on a clock-driven var), so
191
- // the mapping here is total, not a silent coercion.
192
- registry.declareTime(name, {
193
- format: decl.layout,
194
- ttlMs: decl.cache ? parseDuration(decl.cache.ttl) : undefined,
195
- varDefault: decl.default,
196
- });
197
- break;
198
-
199
- case "git":
200
- registry.declareGit(name, {
201
- field: decl.field as GitField,
202
- cwd,
203
- varDefault: decl.default,
204
- });
205
- break;
206
-
207
- case "state":
208
- registry.declareState(name, {
209
- key: decl.key,
210
- ...(decl.default !== undefined && { varDefault: decl.default }),
211
- });
212
- break;
213
- }
214
- }
215
-
216
- // ─── Helpers ─────────────────────────────────────────────────────────────────
217
-
218
- // [LAW:single-enforcer] Compile the config's shared helper templates into ONE
219
- // define set: each name→body is parsed as its own `{{ define "name" }}body{{ end }}`
220
- // unit, chained onto the previous helpers' set, so the result is one `Defines`
221
- // every template this config parses inherits (`engine.parse(src, helpers)`).
222
- // [LAW:one-source-of-truth] Inherited by link, never by copy. The previous
223
- // shape — the defines' SOURCE prepended to every parse — re-parsed the whole
224
- // helper block into every template: ~100 KB of AST per parse for the 2 KB
225
- // stdlib block, ~287 parses per config, ~30 MB per config, and a daemon
226
- // holding twenty configs sat at 600 MB of nothing but duplicated helper ASTs
227
- // (the 2026-09-02 RSS-breach snapshot and the 2026-09-03 crash-loop).
228
- // [LAW:no-silent-fallbacks] Each body is parsed in ISOLATION, so a malformed
229
- // helper surfaces a per-helper diagnostic rather than a confusing error blamed
230
- // on the first segment that happens to call it; the redefinition check the
231
- // parser applies against the inherited set is what makes helper names unique.
232
- function compileHelpers(
233
- engine: Engine<RichText>,
234
- helpers: Readonly<Record<string, string>>,
235
- ): Defines {
236
- let defines = Defines.EMPTY;
237
- for (const [name, body] of Object.entries(helpers)) {
238
- try {
239
- defines = engine
240
- .parse(`{{ define "${name}" }}${body}{{ end }}`, defines)
241
- .defines();
242
- } catch (e) {
243
- throw new Error(
244
- `Template parse error in helpers.${name}: ${(e as Error).message}`,
245
- { cause: e },
246
- );
247
- }
248
- }
249
- return defines;
250
- }
251
-
252
- // ─── registerDslConfig ────────────────────────────────────────────────────────
253
-
254
- /**
255
- * Translate a validated DslConfig into the live VariableStore + SourceRegistry
256
- * and pre-parse all segment templates.
257
- *
258
- * Walks config.variables (global vars) and each segment's vars sub-block
259
- * (namespaced as segName.varName) and calls the matching SourceRegistry
260
- * declare* method for each VariableDecl. Also pre-parses every segment's
261
- * when/template/bg/fg strings once — renderDsl only evaluates.
262
- *
263
- * Call once per config (at startup or hot-reload). The daemon calls this;
264
- * the render loop calls renderDsl with the returned CompiledConfig.
265
- *
266
- * HOT-RELOAD: pass a fresh VariableStore + SourceRegistry on each call.
267
- * defineBox/defineComputed throws if a variable name is already declared in
268
- * the same store — there is no reset or un-declare path. Callers must call
269
- * registry.dispose() on the old registry (to stop timers, watchers, and git
270
- * subscriptions) and then construct new store/registry instances before calling
271
- * again. Dropping the old registry without dispose() leaks resources and may
272
- * keep the process alive.
273
- *
274
- * [LAW:one-source-of-truth] THE JSON-shape→runtime translation. No other
275
- * module re-derives this mapping.
276
- * [LAW:dataflow-not-control-flow] The kind discriminator in declareOne selects
277
- * the declare* call; no special-casing beyond the closed source-kind set.
278
- *
279
- * [LAW:one-source-of-truth] Segment-local vars: stored under the namespaced
280
- * key segName.varName, referenced from templates ONLY via that namespaced
281
- * form. The scope proxy resolves keys literally present in the store, and the
282
- * loader's cross-ref validator enforces the identical rule at load time (a
283
- * bare own-segment ref is a load diagnostic naming the namespaced form).
284
- * Validator and runtime share one definition of what a template may
285
- * reference; bare-name aliasing is deliberately NOT a thing — it would make a
286
- * ref's meaning depend on which segment is rendering instead of on the ref
287
- * string alone.
288
- */
289
- export function registerDslConfig(
290
- config: ValidatedConfig,
291
- registry: SourceRegistry,
292
- opts?: { cwd?: string; clock?: () => Date },
293
- ): CompiledConfig {
294
- const cwd = opts?.cwd ?? process.cwd();
295
-
296
- // [LAW:locality-or-seam] One engine per config load, carrying THIS config's
297
- // action runtime. Engine creation amortizes across all of this config's segment
298
- // templates (parse-once); per-config (not per-render) is the right granularity
299
- // because the action set is config-scoped. The runtime holder is populated below
300
- // — the `action`/`picker` funcs reference the engine, and the compiled actions
301
- // reference the engine, so the holder breaks that cycle.
302
- // [LAW:one-source-of-truth] The action runtime reads through the SAME store the
303
- // registry declares into and the renderer reads back — sourced from the registry
304
- // itself, not a redundant opts field a caller could forget (or pass a divergent
305
- // store for). Every config has a registry, so the action store is never null.
306
- const actionRuntime: ActionRuntime = {
307
- store: registry.variableStore,
308
- compiled: new Map(),
309
- // [LAW:types-are-the-program] Always present — renderDsl republishes the live
310
- // style each render; "powerline" is the registration-time default so a
311
- // compile-only path (no render) still has a valid value.
312
- stripStyle: "powerline",
313
- // Same contract as stripStyle: renderDsl republishes the live resolved
314
- // globals.padding each render; the constant is only the compile-only floor.
315
- padding: DEFAULT_PADDING,
316
- };
317
- // [LAW:one-way-deps] Inject action + picker feature funcs as data — the engine
318
- // stays generic. The picker shares the ACTION runtime (it resolves its
319
- // apply/page actions from the same compiled table), so they read one source.
320
- // [LAW:single-enforcer] Forward the caller's clock (the daemon's `() => new
321
- // Date()`, a test's frozen clock) to the one engine. Omitted ⇒ undefined ⇒
322
- // createCcCandybarEngine applies its single default; no second default literal.
323
- // [LAW:one-source-of-truth] ONE record for "which segment is rendering", read
324
- // by every segment-scoped template function: `{{ menu }}` takes its identity
325
- // from the name, `{{ color }}` its palette, `{{ bgOf }}` its background. A
326
- // per-feature pointer would let two features disagree about which segment is
327
- // current. Built before the engine so the funcs can close over it; `current`
328
- // stays null until a render walk publishes one.
329
- const activeSegment = createActiveSegmentRef();
330
- // [LAW:locality-or-seam] The menu runtime shares the action runtime (a menu's
331
- // glyph + body resolve from the same compiled table + store) and reads the
332
- // active segment through the shared record above.
333
- const menuRuntime: MenuRuntime = {
334
- action: actionRuntime,
335
- activeSegment,
336
- };
337
- // [LAW:one-source-of-truth] The config's look names — the one PER-CONFIG
338
- // option domain. Fed to every consumer (the `looks()` binding below, and —
339
- // via perConfigDomainsFor, the SAME construction cross-ref.ts and
340
- // state-validators.ts use — the compiled set-option domains), so the
341
- // rendered options, a hand-authored `range looks`, and the derived click
342
- // gate (which reads the same config in deriveActionValidators) trace to
343
- // one source.
344
- const lookNames = Object.keys(config.looks);
345
- const presetOptions = presetNames(config.presets);
346
- // [LAW:one-source-of-truth] The "addable segment" per-preset domains merge
347
- // in here — the SAME map config-validators.ts's deriveConfigActionValidators
348
- // merges — so a synthesized `insertSegmentFrom` action's rendered options
349
- // and its derived click gate resolve from one source, never two
350
- // independently-computed sets.
351
- const perConfigDomains = new Map([
352
- ...perConfigDomainsFor(config),
353
- ...addableSegmentDomains(config),
354
- ]);
355
- const engine = createCcCandybarEngine(
356
- {
357
- ...actionFuncs(actionRuntime),
358
- ...pickerFuncs(actionRuntime),
359
- ...menuFuncs(menuRuntime),
360
- // [LAW:one-source-of-truth] `{{ color }}` reads the palette of the
361
- // segment currently rendering — the same palette its `bg:`/`fg:` resolve
362
- // from, published by the walk. Binding it to a palette captured HERE
363
- // (registration runs once per config load, renders happen per tick) was
364
- // the two-clocks bug this seam exists to close: a session theme click, a
365
- // look, or a per-segment hue rotation moved a segment's background while
366
- // every in-body color stayed where it was, so one segment painted from
367
- // two palettes at once. Reading live costs nothing structurally — FuncMap
368
- // bodies run at evaluate time, so parse-once/evaluate-many is untouched.
369
- // `{{ bgOf }}` rides the same record. [LAW:rich-js-owns-color-math]
370
- ...segmentColorFuncs(activeSegment),
371
- // [LAW:one-type-per-behavior] The per-config sibling of the static
372
- // themes()/styles() bindings (template-engine/funcs.ts): zero-arg
373
- // projection of the "looks" option domain. Injected here — not in the
374
- // static FuncMap — because the domain is this config's looks block.
375
- looks: { fn: () => lookNames, argTypes: [] },
376
- // The presets domain's twin of the binding above — same per-config
377
- // reason, same shape. A hand-authored `range presets` and a
378
- // `{{ menu "applyPreset" }}` therefore enumerate the same names the
379
- // derived click gate admits.
380
- presets: { fn: () => presetOptions, argTypes: [] },
381
- },
382
- opts?.clock,
383
- );
384
- // [LAW:single-enforcer] THE one parse path for this config: every template —
385
- // segment template/when/bg/fg, node `when`, and action copy/open — inherits
386
- // the same helper define set, so `{{ template "name" }}` resolves against one
387
- // shared AST. One closure, not raw engine.parse scattered across sites, so
388
- // there is exactly one boundary where helpers come into scope (and one place
389
- // a helper could fail to be visible). The helpers are parsed ONCE here.
390
- const helpers = compileHelpers(engine, config.helpers);
391
- const parse = (src: string): Template<RichText> => engine.parse(src, helpers);
392
- // [LAW:one-source-of-truth] Map each SessionState key → the variable that
393
- // reads it, so an option picker marks its current selection by reading the
394
- // SAME value the templates read — independent of whether the config named the
395
- // variable after the key. State vars are the single read path for SessionState.
396
- const stateKeyToVar = new Map<string, string>();
397
- for (const [name, decl] of Object.entries(config.variables)) {
398
- if (decl.kind === "state" && !stateKeyToVar.has(decl.key)) {
399
- stateKeyToVar.set(decl.key, name);
400
- }
401
- }
402
- // Segment-local state vars read the same SessionState keys; they register
403
- // under the namespaced `segName.varName` (the form the store + scope use), so
404
- // map the key to that namespaced name. Global wins on key collision (added
405
- // first) — the value is the same key regardless, so either reads correctly.
406
- for (const [segName, seg] of Object.entries(config.segments)) {
407
- if (!seg.vars) continue;
408
- for (const [varName, decl] of Object.entries(seg.vars)) {
409
- if (decl.kind === "state" && !stateKeyToVar.has(decl.key)) {
410
- stateKeyToVar.set(decl.key, `${segName}.${varName}`);
411
- }
412
- }
413
- }
414
- // [LAW:one-source-of-truth] Actions resolve their set key → the reading
415
- // variable through the stateKeyToVar map, so an apply action and the picker
416
- // that references it read one value.
417
- actionRuntime.compiled = compileActions(
418
- parse,
419
- config.actions,
420
- stateKeyToVar,
421
- perConfigDomains,
422
- );
423
-
424
- // [LAW:dataflow-not-control-flow] One variable failing to declare does not
425
- // abort the rest. Errors are data (accumulated in loadWarnings); the store
426
- // simply lacks the broken variable. Segments that reference it get a
427
- // MissingFieldError at render time and show an error cell. Segments that
428
- // don't (e.g. configSwitcher) render normally.
429
- const loadWarnings: string[] = [];
430
- for (const [name, decl] of Object.entries(config.variables)) {
431
- try {
432
- declareOne(registry, name, decl, cwd);
433
- } catch (err) {
434
- loadWarnings.push(
435
- `Variable "${name}": ${(err as Error).message ?? String(err)}`,
436
- );
437
- }
438
- }
439
-
440
- // Segment-local vars stored under namespaced key segName.varName.
441
- for (const [segName, seg] of Object.entries(config.segments)) {
442
- if (!seg.vars) continue;
443
- for (const [varName, decl] of Object.entries(seg.vars)) {
444
- try {
445
- declareOne(registry, `${segName}.${varName}`, decl, cwd);
446
- } catch (err) {
447
- loadWarnings.push(
448
- `Variable "${segName}.${varName}": ${(err as Error).message ?? String(err)}`,
449
- );
450
- }
451
- }
452
- }
453
-
454
- // Pre-parse all segment templates and pre-resolve per-segment palettes once.
455
- // renderDsl calls evaluate() only — parse() and palette resolution never
456
- // run in the hot render path.
457
- // [LAW:no-defensive-null-guards] Object.create(null) — segment names come from
458
- // user config; a null-prototype object prevents __proto__/constructor/prototype
459
- // from being treated as segment data.
460
- const compiled: Record<string, CompiledSegment> = Object.create(
461
- null,
462
- ) as Record<string, CompiledSegment>;
463
- for (const [segName, seg] of Object.entries(config.segments)) {
464
- const parseField = (src: string, field: string) => {
465
- try {
466
- return parse(src);
467
- } catch (e) {
468
- throw new Error(
469
- `Template parse error in segments.${segName}.${field}: ${(e as Error).message}`,
470
- { cause: e },
471
- );
472
- }
473
- };
474
- compiled[segName] = {
475
- when: seg.when !== undefined ? parseField(seg.when, "when") : undefined,
476
- template: parseField(seg.template, "template"),
477
- bg: seg.bg !== undefined ? parseField(seg.bg, "bg") : undefined,
478
- fg: seg.fg !== undefined ? parseField(seg.fg, "fg") : undefined,
479
- // [LAW:one-source-of-truth] Freeze ONLY the explicit per-segment `palette:`
480
- // override — a deliberate static pin that intentionally ignores the live
481
- // session theme. The base theme (session ?? globals ?? default) is the
482
- // per-render basePalette; folding globals.palette in here too would freeze
483
- // it per segment and the stale copy would shadow basePalette, so a session
484
- // theme change could never recolor the bar.
485
- palette:
486
- seg.palette !== undefined
487
- ? paletteForThemeName(seg.palette)
488
- : undefined,
489
- };
490
- }
491
-
492
- // [LAW:one-source-of-truth] Compile the layout tree once here, alongside the
493
- // segment templates — renderDsl never parses. This driver owns the cross-cutting
494
- // `when` parse (one site, walk-uniform) and threads the recursion + per-config
495
- // resolution (palette names, state-key→var) into each node type's compile as
496
- // capabilities; the kind-specific assembly lives in node-registry.
497
- // [LAW:single-enforcer] The compiled tree mirrors config.root 1:1, so a node's
498
- // predicate and its children travel together.
499
- const parseNodeField = (src: string, path: string, field: string) => {
500
- try {
501
- return parse(src);
502
- } catch (e) {
503
- throw new Error(
504
- `Template parse error in ${path}.${field}: ${(e as Error).message}`,
505
- { cause: e },
506
- );
507
- }
508
- };
509
- const compileNode = (node: LayoutNode, path: string): CompiledNode => {
510
- const cctx: NodeCompileCtx = {
511
- path,
512
- when:
513
- node.when === undefined
514
- ? undefined
515
- : parseNodeField(node.when, path, "when"),
516
- compileChild: compileNode,
517
- };
518
- return nodeType(node.kind).compile(node, cctx);
519
- };
520
-
521
- // [LAW:one-source-of-truth] One compiled tree per declared preset, built
522
- // through the SAME compileNode the config's own root goes through —
523
- // `presetRoot` resolves the fragment's `root` or falls back to the config's,
524
- // so the floor preset (the empty fragment) needs no arm and no absent case
525
- // downstream.
526
- // Keyed by the SAME domain the menu renders and the click gate admits, so
527
- // every selectable name has a compiled tree [LAW:one-source-of-truth].
528
- const roots = new Map<string, CompiledNode>();
529
- for (const name of presetOptions) {
530
- // The path travels WITH the tree, so a preset that stages the config's own
531
- // root diagnoses under `root` — the place its author actually wrote it.
532
- const { node, path } = presetRoot(config, name);
533
- roots.set(name, compileNode(node, path));
534
- }
535
-
536
- return {
537
- segments: compiled,
538
- roots,
539
- activeSegment,
540
- menuRuntime,
541
- loadWarnings,
542
- };
543
- }
544
-
545
- // [LAW:dataflow-not-control-flow] The focus tint: when a segment's own menu is
546
- // open it is "focused", so its base background is lightened (rich-js owns the
547
- // math — see [[rich-js-owns-color-math]]). The transform is RELATIVE to the
548
- // resolved background, so any host theme tints to a consistent step above its own
549
- // surface; a segment with no background (transparent) has nothing to lighten and
550
- // passes through unchanged. One level ≈ 10% lightness — a subtle "this is active".
551
- const MENU_FOCUS_LIGHTEN_LEVELS = 1;
552
- function focusTint(style: Style): Style {
553
- const bg = style.bgcolor;
554
- if (bg === undefined) return style;
555
- const lit = ColorSpec.fromRgba(
556
- lighten(bg.getTruecolor(), MENU_FOCUS_LIGHTEN_LEVELS),
557
- );
558
- return new Style({ bgcolor: lit, color: style.color });
559
- }
560
-
561
- // ─── renderDsl ───────────────────────────────────────────────────────────────
562
-
563
- /**
564
- * Render the DSL config to a (possibly multi-line) ANSI string.
565
- *
566
- * Pipeline:
567
- * 1. Push payload (+ injected `term.cols`) into input boxes — once per render.
568
- * 2. Build the scope proxy — once per render.
569
- * 3. Walk the compiled layout tree (renderNode) in pre-order, producing a list
570
- * of LINES OF CELLS (not yet serialized). A `container` composes its
571
- * children's blocks by its `direction` (vertical stacks, horizontal zips
572
- * cells per row); a `cells` leaf evaluates its segments into cell lines. A
573
- * node whose `when` (or an ancestor's) is false contributes no line, but its
574
- * segments still advance the hue index so visible siblings keep
575
- * positionally-stable colors.
576
- * 4. Serialize each composed line through the ONE strip joiner and join "\n".
577
- *
578
- * [LAW:single-enforcer] The daemon calls this verbatim — no alternate render
579
- * path. ONE walk renders every layout, flat or nested. The test and the daemon
580
- * share it.
581
- * [LAW:dataflow-not-control-flow] Node visibility, node count, and per-leaf
582
- * segment count are all data; a deeper tree is more recursion, not more code.
583
- * The projection (how a container maps children onto the plane) is the
584
- * `direction` VALUE, not a branch in the walk.
585
- *
586
- * Hue rotation: the segment index driving each `hueShift` advances in pre-order
587
- * across the whole tree, including hidden subtrees. Re-shaping a flat row list
588
- * into nested containers keeps every segment's color; toggling a node's
589
- * visibility does not recolor the nodes after it.
590
- */
591
- // [LAW:locality-or-seam] The optional render observers, bundled as ONE named bag
592
- // so a caller states what it passes by name — no positional tail to count, no
593
- // `undefined` holes to reach a later observer, and a new observer is one field
594
- // here rather than a signature change every caller re-counts.
595
- export interface RenderObservers {
596
- // [LAW:dataflow-not-control-flow] Optional per-segment cell sink. When
597
- // present, each rendered segment's RichText array (post-layout, pre-
598
- // serialization) is written to this map under its segment name. Storing
599
- // cells (not pre-serialized strings) keeps the hot path's serializer
600
- // work proportional to the joined line only — debug consumers serialize
601
- // on demand. Hidden-by-when segments are absent from the map (presence
602
- // = "this segment rendered"). The map is cleared before the first row so
603
- // stale segment names never survive a layout edit. Per-segment standalone
604
- // serialization is not byte-identical to the segment's slice within the
605
- // joined line (powerline joiners sit *between* segments and have no
606
- // place in a one-segment render), but for debug visibility this is the
607
- // natural per-segment shape.
608
- readonly perSegmentSink?: Map<string, readonly RichText[]>;
609
- // [LAW:no-silent-failure] Optional observer for per-segment evaluation errors.
610
- // A failing segment renders as a visible ⚠ error cell (partial rendering, the
611
- // daemon's author-facing channel) — a headless caller with no one looking at
612
- // the bar (`cc-candybar check`) passes this to receive the same errors as
613
- // data and fold them into its text verdict. Trusted non-throwing (the
614
- // registry-dispose contract): an observer that throws is a caller bug
615
- // surfaced loudly, never caught and absorbed by the render walk.
616
- readonly onSegmentError?: (segName: string, message: string) => void;
617
- }
618
-
619
- // [LAW:locality-or-seam] The per-render RESOLUTION the caller performs and hands
620
- // down — the values that are neither config (compiled once) nor payload (input
621
- // data), but the session's live choices resolved against the config: which
622
- // theme-adaptation, which preset. Bundled as ONE named bag for exactly the
623
- // reason RenderObservers is: `look` arrived as a positional tail, `preset` would
624
- // have been a second one, and the next resolution a third — each a signature
625
- // every caller re-counts. A new per-render choice is now one field here.
626
- //
627
- // Both fields default to their domain's own identity element, so an omitting
628
- // caller (a compile-only test, the demo) renders the unadapted config — a true
629
- // default, not a fallback [LAW:no-silent-failure].
630
- export interface RenderSelection {
631
- // The resolved look, as a ThemeKey: effectiveLookName over SessionState/
632
- // globals, then lookKeyByName — resolved by the caller exactly how basePalette
633
- // is. IDENTITY is the "none" look. Composed with each segment's hue shift into
634
- // ONE transposition.
635
- readonly look?: ThemeKey;
636
- // The resolved preset NAME: effectivePresetName over SessionState/globals,
637
- // collapsed to the floor if stale. Selects which of `compiled.roots` this
638
- // render walks. The name (not the fragment) crosses this seam because the
639
- // fragment's two halves land in two different places — the root here, the
640
- // globals in `opts`/the payload — and one name keeps them from disagreeing.
641
- readonly preset?: string;
642
- }
643
-
644
- export function renderDsl(
645
- config: ValidatedConfig,
646
- compiled: CompiledConfig,
647
- store: VariableStore,
648
- registry: SourceRegistry,
649
- payload: unknown,
650
- basePalette: Palette,
651
- opts: BuildLineOptions,
652
- observers?: RenderObservers,
653
- selection?: RenderSelection,
654
- ): string {
655
- const { perSegmentSink, onSegmentError } = observers ?? {};
656
- const { look = IDENTITY, preset = PRESET_FLOOR } = selection ?? {};
657
- // [LAW:one-source-of-truth] Inject the usable width as `term.cols` from the
658
- // SAME opts.width the strip wraps to (below), so a width-paginated widget reads
659
- // the exact wrap width — never a cached or independently-measured copy. This is
660
- // the RAW usable width (terminal cols minus the Claude-Code reserve), the honest
661
- // meaning every template — incl. user configs reading `.term.cols` — expects.
662
- // The picker's strip-chrome reservation is NOT folded in here: that is a
663
- // picker-local concern (the strip's end-caps wrap the picker's row, not every
664
- // segment), applied at the pagination seam in renderPicker. [LAW:locality-or-seam]
665
- // Spreading a non-object payload yields no keys (compile-only callers), so the
666
- // width is set regardless without a trust-boundary guard.
667
- registry.applyInput({ ...(payload as object), term: { cols: opts.width } });
668
- // [LAW:single-enforcer] Publish the render's strip style onto the shared action
669
- // runtime so the picker can reserve the joiner's end-cap chrome at its
670
- // pagination seam (the menu body renders through the same renderPicker). Set
671
- // once per render here — the same one-owner, per-render-mutation idiom as the
672
- // menu placement cursor below. [LAW:no-ambient-temporal-coupling]
673
- compiled.menuRuntime.action.stripStyle = opts.style;
674
- // [LAW:one-source-of-truth] Publish the render's intra-cell padding beside the
675
- // style: the picker reserves 2×padding at its pagination seam, the same seam
676
- // that reserves the joiner chrome — one resolved value, read where needed.
677
- compiled.menuRuntime.action.padding = opts.padding;
678
-
679
- const scope = buildScope(store);
680
- // [LAW:one-source-of-truth] hueStep is a value in the store like every other
681
- // render input — NOT a second source in globals. A config declares the
682
- // conventional hue-step variable and renderDsl reads that one source here. The
683
- // kind decides liveness with no change here: a `state` var lets a stepper drive
684
- // it live (session value over the declared default, the same session-over-
685
- // default the theme uses), a literal pins it (the bundled default's fixed 14°).
686
- // [LAW:no-defensive-null-guards] Two real, representable states both mean "no
687
- // rotation yet" (step 0): the variable is absent (an empty-default merge), OR
688
- // it is a `state` var with no default that no click has written yet (reads the
689
- // registry's empty fallback ""). Coerce to a finite number or 0 — a render must
690
- // never throw on a valid config. Number("") and Number("abc") collapse to the
691
- // 0 floor; any finite value (the literal default, a session pick) flows through.
692
- const rawHue = store.has(HUE_STEP_VAR) ? Number(store.read(HUE_STEP_VAR)) : 0;
693
- const hueStep = Number.isFinite(rawHue) ? rawHue : 0;
694
-
695
- perSegmentSink?.clear();
696
-
697
- // [LAW:single-enforcer] The hue cursor: one counter, advanced in pre-order
698
- // across the whole tree (visible or not) by segment leaves only — a container
699
- // advances none — so per-segment colors stay positionally stable regardless of
700
- // nesting or which nodes are hidden. ctx exposes nextHueShift() as the single
701
- // mutator. Hue is decorative: it carries no structural meaning.
702
- const hue = { value: 0 };
703
- const nextHueShift = (): number => {
704
- const shift = hue.value * hueStep;
705
- hue.value += 1;
706
- return shift;
707
- };
708
-
709
- // [LAW:no-defensive-null-guards] A segment node names one segment; resolve it to
710
- // its decl + compiled form. Both are always present together (loader validates,
711
- // registerDslConfig compiles); a miss is a caller bug the segment render throws on.
712
- const lookupSegment = (name: string) => {
713
- const seg = config.segments[name];
714
- const segCompiled = compiled.segments[name];
715
- return seg !== undefined && segCompiled !== undefined
716
- ? { seg, compiled: segCompiled }
717
- : undefined;
718
- };
719
-
720
- // [LAW:single-enforcer] The segment seam, owned here as a symmetric pair.
721
- // `enterSegment` establishes everything a segment's templates may ask about
722
- // themselves — the name `{{ menu }}` derives its identity from, the palette
723
- // `{{ color }}` resolves against, the background `{{ bgOf }}` returns — and
724
- // returns the resolved base Style. `exitSegment` collects the menu bodies the
725
- // fragments carried as metadata and tears the record back down.
726
- //
727
- // [LAW:no-ambient-temporal-coupling] The record is set and cleared around each
728
- // segment's evaluation by the walk ONLY, so "which segment am I in" is owned
729
- // state with one writer, never ambient context a reader has to hope is
730
- // current. Enter/exit are a pair by construction: every path that publishes
731
- // goes through the first, every path that finishes goes through the second.
732
- const enterSegment = (
733
- segName: string,
734
- palette: Palette,
735
- bgTemplate: Template<RichText> | undefined,
736
- fgTemplate: Template<RichText> | undefined,
737
- ): Style =>
738
- resolveSegmentColors(
739
- compiled.activeSegment,
740
- segName,
741
- palette,
742
- bgTemplate,
743
- fgTemplate,
744
- scope,
745
- );
746
- const exitSegment = (fragments: readonly RichText[]): readonly RichText[] => {
747
- compiled.activeSegment.current = null;
748
- return collectMenuDrops(fragments);
749
- };
750
-
751
- // [LAW:dataflow-not-control-flow] ONE walk renders any node to LINES OF CELLS
752
- // (serialization deferred to the root). The driver owns the cross-cutting
753
- // `when`: `visible` ANDs the node's own predicate with its ancestors'. It then
754
- // dispatches to the node type's render via nodeType() — no kind switch here.
755
- // The node count, nesting depth, and per-leaf segment count are all data; a
756
- // deeper tree is more recursion, not more code.
757
- const renderNode = (
758
- node: CompiledNode,
759
- parentVisible: boolean,
760
- ): RenderedLines => {
761
- const visible = parentVisible && evaluateWhen(node.when, scope);
762
- const ctx: NodeRenderCtx = {
763
- scope,
764
- basePalette,
765
- look,
766
- visible,
767
- padding: opts.padding,
768
- nextHueShift,
769
- perSegmentSink,
770
- onSegmentError,
771
- enterSegment,
772
- exitSegment,
773
- focusTint,
774
- lookupSegment,
775
- renderChild: renderNode,
776
- };
777
- return nodeType(node.kind).render(node, ctx);
778
- };
779
-
780
- // [LAW:single-enforcer] The ONE serialization pass: each composed line of cells
781
- // runs through the strip joiner exactly once, here. renderStripCells may itself
782
- // emit a "\n"-bearing string (FlexStrip width-overflow wrap); joining the per-
783
- // line results with "\n" splices those in place — byte-identical to serializing
784
- // each leaf row independently, since the cells and their order are unchanged.
785
- // [LAW:no-defensive-null-guards] The active preset's compiled tree. By the
786
- // time a name reaches here it must be a member: effectivePresetName collapses
787
- // unknown names to the floor, and every merged config declares the floor — so
788
- // the throw is the loud failure for a broken invariant (a hand-built config
789
- // missing the bundled presets block), never a silent fall back to some other
790
- // arrangement than the one the bar's own label claims is active.
791
- const root = compiled.roots.get(preset);
792
- if (root === undefined) {
793
- throw new Error(
794
- `Preset "${preset}" has no compiled layout — registerDslConfig compiles ` +
795
- `one per declared preset and effectivePresetName collapses unknown ` +
796
- `names to "${PRESET_FLOOR}"; a miss here is merge/policy drift ` +
797
- `(have: ${[...compiled.roots.keys()].join(", ")})`,
798
- );
799
- }
800
- return renderNode(root, true)
801
- .map((line) => renderStripCells(line, opts))
802
- .join("\n");
803
- }