@promptctl/cc-candybar 1.0.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,558 @@
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, PaletteResolver } from "@promptctl/rich-js";
15
+ import type { Engine, Template } from "@promptctl/go-template-js";
16
+ import type {
17
+ ValidatedConfig,
18
+ VariableDecl,
19
+ CacheDecl,
20
+ LayoutNode,
21
+ } from "../config/dsl-types.js";
22
+ import { HUE_STEP_VAR } from "../config/dsl-types.js";
23
+ import type { VariableStore } from "../var-system/store.js";
24
+ import type { SourceRegistry } from "../var-system/sources.js";
25
+ import {
26
+ parseDuration,
27
+ type CachePolicy,
28
+ type GitField,
29
+ } from "../var-system/sources.js";
30
+ import type { BuildLineOptions } from "../render/strip.js";
31
+ import { renderStripCells } from "../render/strip.js";
32
+ import { resolverForThemeName } from "../themes/index.js";
33
+ import { buildScope } from "../template-engine/scope.js";
34
+ import {
35
+ createCcCandybarEngine,
36
+ evaluateWhen,
37
+ } from "../template-engine/index.js";
38
+ import {
39
+ compileActions,
40
+ actionFuncs,
41
+ type ActionRuntime,
42
+ } from "../render/action.js";
43
+ import { pickerFuncs } from "../render/picker.js";
44
+ // [LAW:one-way-deps] The node-type registry sits below this driver: it owns the
45
+ // compiled node shapes + each kind's compile/render, dispatched via nodeType().
46
+ // render.ts threads the recursion (compileChild/renderChild) + the hue counter in
47
+ // as capabilities; it never re-switches on node kind.
48
+ import {
49
+ nodeType,
50
+ type CompiledNode,
51
+ type CompiledSegment,
52
+ type CompiledSegments,
53
+ type RenderedLines,
54
+ type NodeCompileCtx,
55
+ type NodeRenderCtx,
56
+ } from "./node-registry.js";
57
+
58
+ // ─── Compiled config ───────────────────────────────────────────────────────────
59
+
60
+ // [LAW:one-source-of-truth] The full compiled artifact registerDslConfig
61
+ // produces: every segment's compiled templates AND the compiled layout tree
62
+ // (nodes with parsed `when`). renderDsl needs both; bundling them keeps the
63
+ // daemon cache holding one value, not two that could fall out of sync. The
64
+ // compiled node + segment shapes live in node-registry (the render layer that
65
+ // owns node behavior); this driver only assembles + walks them.
66
+ export interface CompiledConfig {
67
+ readonly segments: CompiledSegments;
68
+ readonly root: CompiledNode;
69
+ // [LAW:types-are-the-program] Variable declaration failures that did NOT
70
+ // prevent the config from loading (type mismatches, bad defaults). The
71
+ // affected variables are absent from the store; segments that reference them
72
+ // render as error cells. Non-empty means "partial load" — the config is
73
+ // usable but degraded.
74
+ readonly loadWarnings: readonly string[];
75
+ }
76
+
77
+ // ─── CacheDecl → CachePolicy ─────────────────────────────────────────────────
78
+
79
+ // [LAW:dataflow-not-control-flow] One arm per CacheDecl variant; the in-check
80
+ // is the discriminator, not control flow. Adding a new variant requires one
81
+ // new arm here and a matching CacheDecl arm in dsl-types.
82
+ function toCachePolicy(cache: CacheDecl): CachePolicy {
83
+ if ("ttl" in cache)
84
+ return { kind: "ttl", durationMs: parseDuration(cache.ttl) };
85
+ if ("watch_file" in cache)
86
+ return { kind: "watch_file", path: cache.watch_file };
87
+ if ("depends_on" in cache)
88
+ return { kind: "depends_on", varNames: cache.depends_on };
89
+ if ("key" in cache) return { kind: "key", template: cache.key };
90
+ if ("never" in cache) return { kind: "never" };
91
+ throw new Error(
92
+ `Unknown CacheDecl discriminator — loader invariant violated: ${JSON.stringify(cache)}`,
93
+ );
94
+ }
95
+
96
+ // ─── Single variable declaration ──────────────────────────────────────────────
97
+
98
+ // [LAW:single-enforcer] One function dispatches every VariableDecl kind to its
99
+ // SourceRegistry method. No other code path declares variables.
100
+ function declareOne(
101
+ registry: SourceRegistry,
102
+ name: string,
103
+ decl: VariableDecl,
104
+ cwd: string,
105
+ ): void {
106
+ switch (decl.kind) {
107
+ case "literal":
108
+ registry.declareLiteral(name, decl.value as string | number | boolean);
109
+ break;
110
+
111
+ case "input":
112
+ // [LAW:types-are-the-program] The loader validated that `decl.type` is
113
+ // one of "string"|"number"|"boolean" and that `decl.default` (if
114
+ // present) matches that type. Absent type defaults to "string" — every
115
+ // existing declaration that omits the field reads a string at the
116
+ // resolved payload path.
117
+ registry.declareInput(
118
+ name,
119
+ decl.path,
120
+ decl.type ?? "string",
121
+ decl.default,
122
+ );
123
+ break;
124
+
125
+ case "env":
126
+ registry.declareEnv(name, decl.name, decl.default);
127
+ break;
128
+
129
+ case "file":
130
+ registry.declareFile(name, decl.path, {
131
+ readMode: decl.readMode,
132
+ regex: decl.regex,
133
+ cache: toCachePolicy(decl.cache),
134
+ varDefault: decl.default,
135
+ });
136
+ break;
137
+
138
+ case "shell":
139
+ registry.declareShell(name, decl.command, {
140
+ regex: decl.regex,
141
+ cache: toCachePolicy(decl.cache),
142
+ varDefault: decl.default,
143
+ });
144
+ break;
145
+
146
+ case "template":
147
+ registry.declareTemplate(name, decl.template, {
148
+ varDefault: decl.default,
149
+ });
150
+ break;
151
+
152
+ case "time":
153
+ // [LAW:types-are-the-program] TimeVarDecl.cache is ttl-only by
154
+ // construction — the loader rejects every other CacheDecl form at load
155
+ // (the runtime honors no other invalidation on a clock-driven var), so
156
+ // the mapping here is total, not a silent coercion.
157
+ registry.declareTime(name, {
158
+ format: decl.layout,
159
+ ttlMs: decl.cache ? parseDuration(decl.cache.ttl) : undefined,
160
+ varDefault: decl.default,
161
+ });
162
+ break;
163
+
164
+ case "git":
165
+ registry.declareGit(name, {
166
+ field: decl.field as GitField,
167
+ cwd,
168
+ varDefault: decl.default,
169
+ });
170
+ break;
171
+
172
+ case "state":
173
+ registry.declareState(name, {
174
+ key: decl.key,
175
+ ...(decl.default !== undefined && { varDefault: decl.default }),
176
+ });
177
+ break;
178
+ }
179
+ }
180
+
181
+ // ─── Helper preamble ─────────────────────────────────────────────────────────
182
+
183
+ // [LAW:single-enforcer] Compile the config's shared helper templates into ONE
184
+ // output-neutral preamble: each name→body becomes a `{{ define "name" }}body{{ end }}`
185
+ // block, concatenated with no interstitial text so the preamble emits nothing.
186
+ // Prepended to every template this config parses, the defines resolve a
187
+ // `{{ template "name" .arg }}` call locally — go-template-js scopes defines to a
188
+ // single parse unit, so the define and the call MUST share one parse.
189
+ // [LAW:no-silent-fallbacks] Each body is parsed in ISOLATION first, so a malformed
190
+ // helper surfaces a per-helper diagnostic rather than a confusing error blamed on
191
+ // the first segment that happens to call it.
192
+ // [LAW:dataflow-not-control-flow] Empty helpers ⇒ "" ⇒ `engine.parse("" + src)`
193
+ // is byte-identical to `engine.parse(src)`: existing configs are unaffected with
194
+ // no special-case branch.
195
+ function compileHelperPreamble(
196
+ engine: Engine<RichText>,
197
+ helpers: Readonly<Record<string, string>>,
198
+ ): string {
199
+ let preamble = "";
200
+ for (const [name, body] of Object.entries(helpers)) {
201
+ const define = `{{ define "${name}" }}${body}{{ end }}`;
202
+ try {
203
+ engine.parse(define);
204
+ } catch (e) {
205
+ throw new Error(
206
+ `Template parse error in helpers.${name}: ${(e as Error).message}`,
207
+ { cause: e },
208
+ );
209
+ }
210
+ preamble += define;
211
+ }
212
+ return preamble;
213
+ }
214
+
215
+ // ─── registerDslConfig ────────────────────────────────────────────────────────
216
+
217
+ /**
218
+ * Translate a validated DslConfig into the live VariableStore + SourceRegistry
219
+ * and pre-parse all segment templates.
220
+ *
221
+ * Walks config.variables (global vars) and each segment's vars sub-block
222
+ * (namespaced as segName.varName) and calls the matching SourceRegistry
223
+ * declare* method for each VariableDecl. Also pre-parses every segment's
224
+ * when/template/bg/fg strings once — renderDsl only evaluates.
225
+ *
226
+ * Call once per config (at startup or hot-reload). The daemon calls this;
227
+ * the render loop calls renderDsl with the returned CompiledConfig.
228
+ *
229
+ * HOT-RELOAD: pass a fresh VariableStore + SourceRegistry on each call.
230
+ * defineBox/defineComputed throws if a variable name is already declared in
231
+ * the same store — there is no reset or un-declare path. Callers must call
232
+ * registry.dispose() on the old registry (to stop timers, watchers, and git
233
+ * subscriptions) and then construct new store/registry instances before calling
234
+ * again. Dropping the old registry without dispose() leaks resources and may
235
+ * keep the process alive.
236
+ *
237
+ * [LAW:one-source-of-truth] THE JSON-shape→runtime translation. No other
238
+ * module re-derives this mapping.
239
+ * [LAW:dataflow-not-control-flow] The kind discriminator in declareOne selects
240
+ * the declare* call; no special-casing beyond the closed source-kind set.
241
+ *
242
+ * [LAW:one-source-of-truth] Segment-local vars: stored under the namespaced
243
+ * key segName.varName, referenced from templates ONLY via that namespaced
244
+ * form. The scope proxy resolves keys literally present in the store, and the
245
+ * loader's cross-ref validator enforces the identical rule at load time (a
246
+ * bare own-segment ref is a load diagnostic naming the namespaced form).
247
+ * Validator and runtime share one definition of what a template may
248
+ * reference; bare-name aliasing is deliberately NOT a thing — it would make a
249
+ * ref's meaning depend on which segment is rendering instead of on the ref
250
+ * string alone.
251
+ */
252
+ export function registerDslConfig(
253
+ config: ValidatedConfig,
254
+ registry: SourceRegistry,
255
+ opts?: { cwd?: string; store?: VariableStore; clock?: () => Date },
256
+ ): CompiledConfig {
257
+ const cwd = opts?.cwd ?? process.cwd();
258
+
259
+ // [LAW:locality-or-seam] One engine per config load, carrying THIS config's
260
+ // action runtime. Engine creation amortizes across all of this config's segment
261
+ // templates (parse-once); per-config (not per-render) is the right granularity
262
+ // because the action set is config-scoped. The runtime holder is populated below
263
+ // — the `action`/`picker` funcs reference the engine, and the compiled actions
264
+ // reference the engine, so the holder breaks that cycle.
265
+ // [LAW:no-defensive-null-guards] store may be absent for compile-only callers
266
+ // with no actions; renderAction throws loudly if an action is actually used
267
+ // without a store, rather than silently rendering an empty click.
268
+ const actionRuntime: ActionRuntime = {
269
+ store: opts?.store ?? null,
270
+ compiled: new Map(),
271
+ };
272
+ // [LAW:one-way-deps] Inject action + picker feature funcs as data — the engine
273
+ // stays generic. The picker shares the ACTION runtime (it resolves its
274
+ // apply/page actions from the same compiled table), so they read one source.
275
+ // [LAW:single-enforcer] Forward the caller's clock (the daemon's `() => new
276
+ // Date()`, a test's frozen clock) to the one engine. Omitted ⇒ undefined ⇒
277
+ // createCcCandybarEngine applies its single default; no second default literal.
278
+ const engine = createCcCandybarEngine(
279
+ undefined,
280
+ {
281
+ ...actionFuncs(actionRuntime),
282
+ ...pickerFuncs(actionRuntime),
283
+ },
284
+ opts?.clock,
285
+ );
286
+ // [LAW:single-enforcer] THE one parse path for this config: prepend the helper
287
+ // preamble so every template — segment template/when/bg/fg, node `when`, and
288
+ // action copy/open — resolves `{{ template "name" }}` calls against the same
289
+ // shared helpers. One closure, not raw engine.parse scattered across sites, so
290
+ // there is exactly one boundary where helpers come into scope (and one place a
291
+ // helper could fail to be visible). The preamble is compiled ONCE here, not per
292
+ // parse, and is "" when no helpers are declared.
293
+ const helperPreamble = compileHelperPreamble(engine, config.helpers);
294
+ const parse = (src: string): Template<RichText> =>
295
+ engine.parse(helperPreamble + src);
296
+ // [LAW:one-source-of-truth] Map each SessionState key → the variable that
297
+ // reads it, so an option picker marks its current selection by reading the
298
+ // SAME value the templates read — independent of whether the config named the
299
+ // variable after the key. State vars are the single read path for SessionState.
300
+ const stateKeyToVar = new Map<string, string>();
301
+ for (const [name, decl] of Object.entries(config.variables)) {
302
+ if (decl.kind === "state" && !stateKeyToVar.has(decl.key)) {
303
+ stateKeyToVar.set(decl.key, name);
304
+ }
305
+ }
306
+ // Segment-local state vars read the same SessionState keys; they register
307
+ // under the namespaced `segName.varName` (the form the store + scope use), so
308
+ // map the key to that namespaced name. Global wins on key collision (added
309
+ // first) — the value is the same key regardless, so either reads correctly.
310
+ for (const [segName, seg] of Object.entries(config.segments)) {
311
+ if (!seg.vars) continue;
312
+ for (const [varName, decl] of Object.entries(seg.vars)) {
313
+ if (decl.kind === "state" && !stateKeyToVar.has(decl.key)) {
314
+ stateKeyToVar.set(decl.key, `${segName}.${varName}`);
315
+ }
316
+ }
317
+ }
318
+ // [LAW:one-source-of-truth] Actions resolve their set key → the reading
319
+ // variable through the stateKeyToVar map, so an apply action and the picker
320
+ // that references it read one value.
321
+ actionRuntime.compiled = compileActions(parse, config.actions, stateKeyToVar);
322
+
323
+ // [LAW:dataflow-not-control-flow] One variable failing to declare does not
324
+ // abort the rest. Errors are data (accumulated in loadWarnings); the store
325
+ // simply lacks the broken variable. Segments that reference it get a
326
+ // MissingFieldError at render time and show an error cell. Segments that
327
+ // don't (e.g. configSwitcher) render normally.
328
+ const loadWarnings: string[] = [];
329
+ for (const [name, decl] of Object.entries(config.variables)) {
330
+ try {
331
+ declareOne(registry, name, decl, cwd);
332
+ } catch (err) {
333
+ loadWarnings.push(
334
+ `Variable "${name}": ${(err as Error).message ?? String(err)}`,
335
+ );
336
+ }
337
+ }
338
+
339
+ // Segment-local vars stored under namespaced key segName.varName.
340
+ for (const [segName, seg] of Object.entries(config.segments)) {
341
+ if (!seg.vars) continue;
342
+ for (const [varName, decl] of Object.entries(seg.vars)) {
343
+ try {
344
+ declareOne(registry, `${segName}.${varName}`, decl, cwd);
345
+ } catch (err) {
346
+ loadWarnings.push(
347
+ `Variable "${segName}.${varName}": ${(err as Error).message ?? String(err)}`,
348
+ );
349
+ }
350
+ }
351
+ }
352
+
353
+ // Pre-parse all segment templates and pre-resolve per-segment palettes once.
354
+ // renderDsl calls evaluate() only — parse() and palette resolution never
355
+ // run in the hot render path.
356
+ // [LAW:no-defensive-null-guards] Object.create(null) — segment names come from
357
+ // user config; a null-prototype object prevents __proto__/constructor/prototype
358
+ // from being treated as segment data.
359
+ const compiled: Record<string, CompiledSegment> = Object.create(
360
+ null,
361
+ ) as Record<string, CompiledSegment>;
362
+ for (const [segName, seg] of Object.entries(config.segments)) {
363
+ const parseField = (src: string, field: string) => {
364
+ try {
365
+ return parse(src);
366
+ } catch (e) {
367
+ throw new Error(
368
+ `Template parse error in segments.${segName}.${field}: ${(e as Error).message}`,
369
+ { cause: e },
370
+ );
371
+ }
372
+ };
373
+ compiled[segName] = {
374
+ when: seg.when !== undefined ? parseField(seg.when, "when") : undefined,
375
+ template: parseField(seg.template, "template"),
376
+ bg: seg.bg !== undefined ? parseField(seg.bg, "bg") : undefined,
377
+ fg: seg.fg !== undefined ? parseField(seg.fg, "fg") : undefined,
378
+ // [LAW:one-source-of-truth] Freeze ONLY the explicit per-segment `palette:`
379
+ // override — a deliberate static pin that intentionally ignores the live
380
+ // session theme. The base theme (session ?? globals ?? default) is the
381
+ // per-render basePalette; folding globals.palette in here too would freeze
382
+ // it per segment and the stale copy would shadow basePalette, so a session
383
+ // theme change could never recolor the bar.
384
+ paletteResolver:
385
+ seg.palette !== undefined
386
+ ? resolverForThemeName(seg.palette)
387
+ : undefined,
388
+ };
389
+ }
390
+
391
+ // [LAW:one-source-of-truth] Compile the layout tree once here, alongside the
392
+ // segment templates — renderDsl never parses. This driver owns the cross-cutting
393
+ // `when` parse (one site, walk-uniform) and threads the recursion + per-config
394
+ // resolution (palette names, state-key→var) into each node type's compile as
395
+ // capabilities; the kind-specific assembly lives in node-registry.
396
+ // [LAW:single-enforcer] The compiled tree mirrors config.root 1:1, so a node's
397
+ // predicate and its children travel together.
398
+ const parseNodeField = (src: string, path: string, field: string) => {
399
+ try {
400
+ return parse(src);
401
+ } catch (e) {
402
+ throw new Error(
403
+ `Template parse error in ${path}.${field}: ${(e as Error).message}`,
404
+ { cause: e },
405
+ );
406
+ }
407
+ };
408
+ const compileNode = (node: LayoutNode, path: string): CompiledNode => {
409
+ const cctx: NodeCompileCtx = {
410
+ path,
411
+ when:
412
+ node.when === undefined
413
+ ? undefined
414
+ : parseNodeField(node.when, path, "when"),
415
+ compileChild: compileNode,
416
+ };
417
+ return nodeType(node.kind).compile(node, cctx);
418
+ };
419
+
420
+ return {
421
+ segments: compiled,
422
+ root: compileNode(config.root, "root"),
423
+ loadWarnings,
424
+ };
425
+ }
426
+
427
+ // ─── renderDsl ───────────────────────────────────────────────────────────────
428
+
429
+ /**
430
+ * Render the DSL config to a (possibly multi-line) ANSI string.
431
+ *
432
+ * Pipeline:
433
+ * 1. Push payload (+ injected `term.cols`) into input boxes — once per render.
434
+ * 2. Build the scope proxy — once per render.
435
+ * 3. Walk the compiled layout tree (renderNode) in pre-order, producing a list
436
+ * of LINES OF CELLS (not yet serialized). A `container` composes its
437
+ * children's blocks by its `direction` (vertical stacks, horizontal zips
438
+ * cells per row); a `cells` leaf evaluates its segments into cell lines. A
439
+ * node whose `when` (or an ancestor's) is false contributes no line, but its
440
+ * segments still advance the hue index so visible siblings keep
441
+ * positionally-stable colors.
442
+ * 4. Serialize each composed line through the ONE strip joiner and join "\n".
443
+ *
444
+ * [LAW:single-enforcer] The daemon calls this verbatim — no alternate render
445
+ * path. ONE walk renders every layout, flat or nested. The test and the daemon
446
+ * share it.
447
+ * [LAW:dataflow-not-control-flow] Node visibility, node count, and per-leaf
448
+ * segment count are all data; a deeper tree is more recursion, not more code.
449
+ * The projection (how a container maps children onto the plane) is the
450
+ * `direction` VALUE, not a branch in the walk.
451
+ *
452
+ * Hue rotation: the segment index driving each `hueShift` advances in pre-order
453
+ * across the whole tree, including hidden subtrees. Re-shaping a flat row list
454
+ * into nested containers keeps every segment's color; toggling a node's
455
+ * visibility does not recolor the nodes after it.
456
+ */
457
+ export function renderDsl(
458
+ config: ValidatedConfig,
459
+ compiled: CompiledConfig,
460
+ store: VariableStore,
461
+ registry: SourceRegistry,
462
+ payload: unknown,
463
+ basePalette: PaletteResolver,
464
+ opts: BuildLineOptions,
465
+ // [LAW:dataflow-not-control-flow] Optional per-segment cell sink. When
466
+ // present, each rendered segment's RichText array (post-layout, pre-
467
+ // serialization) is written to this map under its segment name. Storing
468
+ // cells (not pre-serialized strings) keeps the hot path's serializer
469
+ // work proportional to the joined line only — debug consumers serialize
470
+ // on demand. Hidden-by-when segments are absent from the map (presence
471
+ // = "this segment rendered"). The map is cleared before the first row so
472
+ // stale segment names never survive a layout edit. Per-segment standalone
473
+ // serialization is not byte-identical to the segment's slice within the
474
+ // joined line (powerline joiners sit *between* segments and have no
475
+ // place in a one-segment render), but for debug visibility this is the
476
+ // natural per-segment shape.
477
+ perSegmentSink?: Map<string, readonly RichText[]>,
478
+ ): string {
479
+ // [LAW:one-source-of-truth] Inject the usable width as `term.cols` from the
480
+ // SAME opts.width the strip wraps to (below), so a width-paginated widget
481
+ // reads the exact wrap width — never a cached or independently-measured copy.
482
+ // Spreading a non-object payload yields no keys (compile-only callers), so the
483
+ // width is set regardless without a trust-boundary guard.
484
+ registry.applyInput({ ...(payload as object), term: { cols: opts.width } });
485
+
486
+ const scope = buildScope(store);
487
+ // [LAW:one-source-of-truth] hueStep is a value in the store like every other
488
+ // render input — NOT a second source in globals. A config declares the
489
+ // conventional hue-step variable and renderDsl reads that one source here. The
490
+ // kind decides liveness with no change here: a `state` var lets a stepper drive
491
+ // it live (session value over the declared default, the same session-over-
492
+ // default the theme uses), a literal pins it (the bundled default's fixed 14°).
493
+ // [LAW:no-defensive-null-guards] Two real, representable states both mean "no
494
+ // rotation yet" (step 0): the variable is absent (an empty-default merge), OR
495
+ // it is a `state` var with no default that no click has written yet (reads the
496
+ // registry's empty fallback ""). Coerce to a finite number or 0 — a render must
497
+ // never throw on a valid config. Number("") and Number("abc") collapse to the
498
+ // 0 floor; any finite value (the literal default, a session pick) flows through.
499
+ const rawHue = store.has(HUE_STEP_VAR) ? Number(store.read(HUE_STEP_VAR)) : 0;
500
+ const hueStep = Number.isFinite(rawHue) ? rawHue : 0;
501
+
502
+ perSegmentSink?.clear();
503
+
504
+ // [LAW:single-enforcer] The hue cursor: one counter, advanced in pre-order
505
+ // across the whole tree (visible or not) by segment leaves only — a container
506
+ // advances none — so per-segment colors stay positionally stable regardless of
507
+ // nesting or which nodes are hidden. ctx exposes nextHueShift() as the single
508
+ // mutator. Hue is decorative: it carries no structural meaning.
509
+ const hue = { value: 0 };
510
+ const nextHueShift = (): number => {
511
+ const shift = hue.value * hueStep;
512
+ hue.value += 1;
513
+ return shift;
514
+ };
515
+
516
+ // [LAW:no-defensive-null-guards] A segment node names one segment; resolve it to
517
+ // its decl + compiled form. Both are always present together (loader validates,
518
+ // registerDslConfig compiles); a miss is a caller bug the segment render throws on.
519
+ const lookupSegment = (name: string) => {
520
+ const seg = config.segments[name];
521
+ const segCompiled = compiled.segments[name];
522
+ return seg !== undefined && segCompiled !== undefined
523
+ ? { seg, compiled: segCompiled }
524
+ : undefined;
525
+ };
526
+
527
+ // [LAW:dataflow-not-control-flow] ONE walk renders any node to LINES OF CELLS
528
+ // (serialization deferred to the root). The driver owns the cross-cutting
529
+ // `when`: `visible` ANDs the node's own predicate with its ancestors'. It then
530
+ // dispatches to the node type's render via nodeType() — no kind switch here.
531
+ // The node count, nesting depth, and per-leaf segment count are all data; a
532
+ // deeper tree is more recursion, not more code.
533
+ const renderNode = (
534
+ node: CompiledNode,
535
+ parentVisible: boolean,
536
+ ): RenderedLines => {
537
+ const visible = parentVisible && evaluateWhen(node.when, scope);
538
+ const ctx: NodeRenderCtx = {
539
+ scope,
540
+ basePalette,
541
+ visible,
542
+ nextHueShift,
543
+ perSegmentSink,
544
+ lookupSegment,
545
+ renderChild: renderNode,
546
+ };
547
+ return nodeType(node.kind).render(node, ctx);
548
+ };
549
+
550
+ // [LAW:single-enforcer] The ONE serialization pass: each composed line of cells
551
+ // runs through the strip joiner exactly once, here. renderStripCells may itself
552
+ // emit a "\n"-bearing string (FlexStrip width-overflow wrap); joining the per-
553
+ // line results with "\n" splices those in place — byte-identical to serializing
554
+ // each leaf row independently, since the cells and their order are unchanged.
555
+ return renderNode(compiled.root, true)
556
+ .map((line) => renderStripCells(line, opts))
557
+ .join("\n");
558
+ }