@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,688 @@
1
+ // [LAW:one-source-of-truth] The ONE layout authoring surface is the A-grammar
2
+ // (a bare string = segment ref; { seg, when? } = segment ref with predicate;
3
+ // { h: [...], when? } = horizontal container; { v: [...], when? } = vertical
4
+ // container; { kind: "group", … } = collapsible group). ALL other shapes are
5
+ // migration errors [LAW:no-silent-failure]:
6
+ //
7
+ // `layout:` top-level key (removed in 2de.19) → error with A-grammar rewrite
8
+ // `kind: "cells"` node (removed in 2de.19) → error with { h: […] } rewrite
9
+ //
10
+ // [LAW:types-are-the-program] The node grammar is DATA schemas interpreted by the
11
+ // generic `record` engine: each arm's shape is a FieldSpecMap, each bespoke
12
+ // message lives on its field spec as data. The two things the generic engine does
13
+ // NOT own stay local: the kind-dispatch (a node folds object-guard / missing-kind
14
+ // / unknown-kind into one bespoke message and pins its line to `root`, unlike the
15
+ // generic taggedUnion's per-failure messages), and the degenerate-node recovery
16
+ // (a node never drops to null; it recovers so traversal keeps collecting issues —
17
+ // parseDslConfig throws once any issue exists, so the fallback never renders). The
18
+ // recursion (a container's children are nodes) crosses through `lazy`, the engine's
19
+ // recursion seam, so the child-list field is data that points back at the node
20
+ // parser without a temporal-dead-zone crash at module load.
21
+
22
+ import {
23
+ DIRECTIONS,
24
+ type ContainerNode,
25
+ type Direction,
26
+ type LayoutNode,
27
+ type RawDslConfig,
28
+ type SegmentDecl,
29
+ type SegmentNode,
30
+ type VariableDecl,
31
+ } from "../dsl-types.js";
32
+ import type { ActionDecl } from "../action.js";
33
+ import { findKeyLine } from "./diagnostics.js";
34
+ import {
35
+ describeType,
36
+ describeValue,
37
+ isPlainObject,
38
+ lazy,
39
+ optionalBooleanSpec,
40
+ optionalEnumSpec,
41
+ optionalStringSpec,
42
+ record,
43
+ recordJson,
44
+ requireString,
45
+ type FieldSpec,
46
+ type JsonNode,
47
+ type Mutable,
48
+ type RecordSchema,
49
+ type ValidateCtx,
50
+ } from "./validate-core.js";
51
+
52
+ // [LAW:types-are-the-program] The recursion seam for EMIT: a container's children
53
+ // are LayoutNodes, so the node schema must reference itself. JSON Schema breaks
54
+ // the cycle with a named definition + `$ref` — the structural analogue of the
55
+ // `lazy` thunk that breaks the parse-time cycle. The emitter publishes the node
56
+ // schema at this path; `childrenSpec` and the top-level `root` both point here.
57
+ export const LAYOUT_NODE_REF = "#/definitions/LayoutNode";
58
+ export const LAYOUT_NODE_DEF_NAME = "LayoutNode";
59
+
60
+ // ─── Root node grammar (`root`) ──────────────────────────────────────────────
61
+
62
+ // [LAW:dataflow-not-control-flow] On a fundamental shape error (non-object node or
63
+ // unknown kind) the dispatch returns a degenerate node so traversal continues
64
+ // collecting issues — parseDslConfig throws once any issue exists, so the fallback
65
+ // never renders. This is the recovery shape the generic `record`/union engines do
66
+ // NOT own (they drop to null); it stays local as a separate pass over the engine.
67
+ const EMPTY_VERTICAL_NODE: LayoutNode = {
68
+ kind: "container",
69
+ direction: "vertical",
70
+ children: [],
71
+ };
72
+
73
+ // [LAW:types-are-the-program] A node's `kind` is a literal the dispatch has already
74
+ // validated; as a record field it is included (so the unknown-key rejection allows
75
+ // it) and yields the literal back. It can never be absent or wrong here — the
76
+ // dispatch routes to this arm only on an exact kind match.
77
+ // `required: true` though parse never fails — it's mandatory in the emitted
78
+ // schema (the const discriminator), a no-op for `fields`. See `cellsSegmentsSpec`.
79
+ function literalSpec<V extends string>(value: V): FieldSpec<V> {
80
+ return { required: true, json: { const: value }, parse: () => value };
81
+ }
82
+
83
+ // [LAW:dataflow-not-control-flow] A segment node's `name`: present-non-empty-string
84
+ // → the name; anything else → the bespoke issue plus a `""` fallback (NOT a drop),
85
+ // so the node recovers and traversal continues. The fallback IS the value (never
86
+ // undefined), so the record always keeps the field.
87
+ function segmentNameSpec(): FieldSpec<string> {
88
+ return {
89
+ // Mandatory in the schema (a missing/empty name pushes an issue → throw); the
90
+ // parse recovers to "" so it's a no-op for `fields`. See `cellsSegmentsSpec`.
91
+ required: true,
92
+ json: { type: "string" },
93
+ parse: (ctx, path, field, raw) => {
94
+ const v = raw[field];
95
+ if (typeof v === "string" && v.length > 0) return v;
96
+ ctx.issues.push({
97
+ path: `${path}.${field}`,
98
+ message: `a segment node must have a non-empty "name" (a segment name), got ${describeValue(v)}`,
99
+ line: findKeyLine(ctx.source, ["root"]),
100
+ });
101
+ return "";
102
+ },
103
+ };
104
+ }
105
+
106
+ // [LAW:one-source-of-truth] Valid directions come from the DIRECTIONS list — the
107
+ // same set the renderer projects. An invalid/absent direction recovers to
108
+ // `vertical` (the node is never dropped) plus the bespoke issue. Distinct from the
109
+ // generic `optionalEnumSpec`, which OMITS on invalid; a container's `direction` is
110
+ // required, so it must recover to a value, not vanish.
111
+ function directionSpec(): FieldSpec<Direction> {
112
+ return {
113
+ // Mandatory in the schema (a missing/invalid direction pushes an issue →
114
+ // throw); the parse recovers to "vertical", a no-op for `fields`. See
115
+ // `cellsSegmentsSpec`.
116
+ required: true,
117
+ json: { enum: [...DIRECTIONS] },
118
+ parse: (ctx, path, field, raw) => {
119
+ const v = raw[field];
120
+ if (
121
+ typeof v === "string" &&
122
+ (DIRECTIONS as readonly string[]).includes(v)
123
+ ) {
124
+ return v as Direction;
125
+ }
126
+ ctx.issues.push({
127
+ path: `${path}.${field}`,
128
+ message: `a container "direction" must be one of: ${DIRECTIONS.join(", ")} (got ${JSON.stringify(v)})`,
129
+ line: findKeyLine(ctx.source, ["root"]),
130
+ });
131
+ return "vertical";
132
+ },
133
+ };
134
+ }
135
+
136
+ // [LAW:decomposition] A container's `children` are themselves nodes — the one
137
+ // recursive field. It recovers to `[]` on a non-array value (the node is kept),
138
+ // and otherwise maps each child through the node parser. The parser is referenced
139
+ // through `lazy` so this spec can live inside CONTAINER_SCHEMA as data that points
140
+ // back at `validateRoot` without a temporal-dead-zone read at module load.
141
+ function childrenSpec(
142
+ node: (ctx: ValidateCtx, path: string, raw: unknown) => LayoutNode,
143
+ ): FieldSpec<readonly LayoutNode[]> {
144
+ return {
145
+ // Mandatory in the schema (a missing/non-array `children` pushes an issue →
146
+ // throw); the parse recovers to [], a no-op for `fields`. See `cellsSegmentsSpec`.
147
+ required: true,
148
+ // [LAW:one-source-of-truth] The recursive field points at the node definition
149
+ // via `$ref` — emit's analogue of the `lazy` thunk that defers the parse-time
150
+ // self-reference. The runtime recursion and the schema recursion break the
151
+ // same cycle, declared in one place.
152
+ json: { type: "array", items: { $ref: LAYOUT_NODE_REF } },
153
+ parse: (ctx, path, field, raw) => {
154
+ const v = raw[field];
155
+ if (!Array.isArray(v)) {
156
+ ctx.issues.push({
157
+ path: `${path}.${field}`,
158
+ message: `a container must have a "children" array of layout nodes, got ${describeType(v)}`,
159
+ line: findKeyLine(ctx.source, ["root"]),
160
+ });
161
+ return [];
162
+ }
163
+ return v.map((child, i) => node(ctx, `${path}.${field}[${i}]`, child));
164
+ },
165
+ };
166
+ }
167
+
168
+ const SEGMENT_NODE_SCHEMA: RecordSchema<SegmentNode> = {
169
+ noun: "layout-node key",
170
+ fields: {
171
+ kind: literalSpec("segment"),
172
+ name: segmentNameSpec(),
173
+ when: optionalStringSpec(),
174
+ },
175
+ };
176
+
177
+ const CONTAINER_SCHEMA: RecordSchema<ContainerNode> = {
178
+ noun: "layout-node key",
179
+ fields: {
180
+ kind: literalSpec("container"),
181
+ direction: directionSpec(),
182
+ children: childrenSpec(lazy(() => validateRoot)),
183
+ when: optionalStringSpec(),
184
+ },
185
+ };
186
+
187
+ // ─── Option A shape grammar (seg / h / v) ────────────────────────────────────
188
+
189
+ // [LAW:types-are-the-program] The terse bijective spellings of the canonical
190
+ // tree: a bare string names a segment; an object with exactly one of "seg",
191
+ // "h", or "v" spells a segment-ref-with-predicate, a horizontal container, or
192
+ // a vertical container respectively. Every legal canonical node is expressible;
193
+ // no illegal one is — bijectivity is the acceptance test. The key-count check
194
+ // (exactly one of seg/h/v) is the dispatch-level invariant that makes the wrong
195
+ // arm unrepresentable as a valid parse. [LAW:single-enforcer] — the loader is
196
+ // the sole enforcer; the JSON Schema emitter mirrors it, but the loader's exit
197
+ // code is the truth.
198
+
199
+ interface SegArmNode {
200
+ readonly seg: string;
201
+ readonly when?: string;
202
+ }
203
+
204
+ function segArmSpec(): FieldSpec<string> {
205
+ return {
206
+ required: true,
207
+ json: { type: "string" },
208
+ parse: (ctx, path, field, raw) => {
209
+ const v = raw[field];
210
+ if (typeof v === "string" && v.length > 0) return v;
211
+ ctx.issues.push({
212
+ path: `${path}.${field}`,
213
+ message: `a "seg" node must have a non-empty segment name, got ${describeValue(v)}`,
214
+ line: findKeyLine(ctx.source, ["root"]),
215
+ });
216
+ return "";
217
+ },
218
+ };
219
+ }
220
+
221
+ const SEG_ARM_SCHEMA: RecordSchema<SegArmNode> = {
222
+ noun: "layout-node key",
223
+ fields: { seg: segArmSpec(), when: optionalStringSpec() },
224
+ };
225
+
226
+ interface HArmNode {
227
+ readonly h: readonly LayoutNode[];
228
+ readonly when?: string;
229
+ }
230
+
231
+ const H_ARM_SCHEMA: RecordSchema<HArmNode> = {
232
+ noun: "layout-node key",
233
+ fields: {
234
+ h: childrenSpec(lazy(() => validateRoot)),
235
+ when: optionalStringSpec(),
236
+ },
237
+ };
238
+
239
+ interface VArmNode {
240
+ readonly v: readonly LayoutNode[];
241
+ readonly when?: string;
242
+ }
243
+
244
+ const V_ARM_SCHEMA: RecordSchema<VArmNode> = {
245
+ noun: "layout-node key",
246
+ fields: {
247
+ v: childrenSpec(lazy(() => validateRoot)),
248
+ when: optionalStringSpec(),
249
+ },
250
+ };
251
+
252
+ // ─── validateRoot ────────────────────────────────────────────────────────────
253
+
254
+ // [LAW:locality-or-seam] The boundary that turns the raw `root` grammar into a
255
+ // validated LayoutNode tree. STRUCTURAL only — whether a segment name resolves and
256
+ // whether a `when` ref exists are cross-ref concerns (validateCrossReferences runs
257
+ // on the MERGED config, so a node can name default-provided segments).
258
+ //
259
+ // [LAW:dataflow-not-control-flow] The `kind` discriminator selects the arm; an
260
+ // unknown kind is rejected, never coerced. Object-guard and unknown-kind fold into
261
+ // one bespoke message each (pinned to the `root` line) — the local dispatch the
262
+ // generic taggedUnion does not express. A `const` (not a hoisted function) so the
263
+ // `lazy` thunk inside CONTAINER_SCHEMA defers reading it; reading it eagerly there
264
+ // would be a temporal-dead-zone crash.
265
+ export const validateRoot = (
266
+ ctx: ValidateCtx,
267
+ path: string,
268
+ raw: unknown,
269
+ ): LayoutNode => {
270
+ // [LAW:types-are-the-program] A bare string is the terse segment-ref spelling.
271
+ // Checked before the object guard so the "not an object" error does not fire
272
+ // on a valid input.
273
+ if (typeof raw === "string") {
274
+ if (raw.length === 0) {
275
+ ctx.issues.push({
276
+ path,
277
+ message: `a bare-string layout node must be a non-empty segment name`,
278
+ line: findKeyLine(ctx.source, ["root"]),
279
+ });
280
+ return EMPTY_VERTICAL_NODE;
281
+ }
282
+ return { kind: "segment", name: raw };
283
+ }
284
+ if (!isPlainObject(raw)) {
285
+ ctx.issues.push({
286
+ path,
287
+ message: `a layout node must be a string (segment name) or an object with "kind" / "seg" / "h" / "v", got ${describeType(raw)}`,
288
+ line: findKeyLine(ctx.source, ["root"]),
289
+ });
290
+ return EMPTY_VERTICAL_NODE;
291
+ }
292
+ if (raw.kind === "container") {
293
+ return record(ctx, CONTAINER_SCHEMA, path, raw) ?? EMPTY_VERTICAL_NODE;
294
+ }
295
+ if (raw.kind === "segment") {
296
+ return record(ctx, SEGMENT_NODE_SCHEMA, path, raw) ?? EMPTY_VERTICAL_NODE;
297
+ }
298
+ if (raw.kind === "cells") {
299
+ // [LAW:no-silent-failure] `kind: "cells"` removed in 2de.19. Reject loudly
300
+ // with the A-grammar equivalent so the author knows exactly how to migrate.
301
+ ctx.issues.push({
302
+ path,
303
+ message: `kind: "cells" is no longer supported — use the h-arm spelling instead:\n Old: { kind: "cells", segments: ["seg1", "seg2"] }\n New: { h: ["seg1", "seg2"] }`,
304
+ line: findKeyLine(ctx.source, ["root"]),
305
+ });
306
+ return EMPTY_VERTICAL_NODE;
307
+ }
308
+ if (raw.kind === "group") {
309
+ const group = record(ctx, GROUP_SCHEMA, path, raw);
310
+ if (group === null) return EMPTY_VERTICAL_NODE;
311
+ // Collected for the post-walk synthesis pass (state var + cycle action +
312
+ // toggle segment); the node itself lowers to the canonical grammar here.
313
+ ctx.groups.push({
314
+ name: group.name,
315
+ label: group.label,
316
+ ...(group.open !== undefined && { open: group.open }),
317
+ ...(group.direction !== undefined && { direction: group.direction }),
318
+ ...(group.key !== undefined && { key: group.key }),
319
+ ...(group.bg !== undefined && { bg: group.bg }),
320
+ ...(group.fg !== undefined && { fg: group.fg }),
321
+ ...(group.when !== undefined && { when: group.when }),
322
+ path,
323
+ });
324
+ return lowerGroup(group);
325
+ }
326
+ // [LAW:types-are-the-program] Option A terse arms: exactly one of "seg" / "h"
327
+ // / "v". Two or more present is an illegal state; zero means the object has
328
+ // neither a valid "kind" nor a valid terse arm — both are loud rejections.
329
+ const hasH = "h" in raw;
330
+ const hasV = "v" in raw;
331
+ const hasSeg = "seg" in raw;
332
+ const armCount = (hasH ? 1 : 0) + (hasV ? 1 : 0) + (hasSeg ? 1 : 0);
333
+ if (armCount > 1) {
334
+ const present = (["seg", "h", "v"] as const).filter((k) => k in raw);
335
+ ctx.issues.push({
336
+ path,
337
+ message: `a layout node may have exactly one of "seg", "h", or "v" — got ${present.map((k) => `"${k}"`).join(" and ")} together`,
338
+ line: findKeyLine(ctx.source, ["root"]),
339
+ });
340
+ return EMPTY_VERTICAL_NODE;
341
+ }
342
+ if (hasSeg) {
343
+ const arm = record(ctx, SEG_ARM_SCHEMA, path, raw);
344
+ if (arm === null) return EMPTY_VERTICAL_NODE;
345
+ return {
346
+ kind: "segment",
347
+ name: arm.seg,
348
+ ...(arm.when !== undefined && { when: arm.when }),
349
+ };
350
+ }
351
+ if (hasH) {
352
+ const arm = record(ctx, H_ARM_SCHEMA, path, raw);
353
+ if (arm === null) return EMPTY_VERTICAL_NODE;
354
+ return {
355
+ kind: "container",
356
+ direction: "horizontal",
357
+ children: arm.h,
358
+ ...(arm.when !== undefined && { when: arm.when }),
359
+ };
360
+ }
361
+ if (hasV) {
362
+ const arm = record(ctx, V_ARM_SCHEMA, path, raw);
363
+ if (arm === null) return EMPTY_VERTICAL_NODE;
364
+ return {
365
+ kind: "container",
366
+ direction: "vertical",
367
+ children: arm.v,
368
+ ...(arm.when !== undefined && { when: arm.when }),
369
+ };
370
+ }
371
+ ctx.issues.push({
372
+ path: `${path}.kind`,
373
+ message: `a layout node "kind" must be "container", "segment", or "group", or use the terse A-grammar: a bare string, or an object with "seg", "h", or "v" (got ${JSON.stringify(raw.kind)})`,
374
+ line: findKeyLine(ctx.source, ["root"]),
375
+ });
376
+ return EMPTY_VERTICAL_NODE;
377
+ };
378
+
379
+ // ─── Group sugar (`kind: "group"`) ───────────────────────────────────────────
380
+
381
+ // [LAW:one-source-of-truth] The reserved namespace every synthesized artifact
382
+ // lives under, in all three sections (variables / actions / segments). One
383
+ // group declaration is the single source; the var, the action, and the toggle
384
+ // segment all derive their name from it. A user-authored name under this
385
+ // prefix is rejected so synthesis can never silently collide.
386
+ export const GROUP_NS = "groups.";
387
+
388
+ // The "no group open" sentinel a group's cycle starts from. Group names are
389
+ // forbidden from equaling it, so a cycle's two members are always distinct.
390
+ const GROUP_CLOSED = "closed";
391
+
392
+ // [LAW:types-are-the-program] A group name must be template-addressable — it is
393
+ // spliced into the synthesized `when` predicate and toggle template as
394
+ // `.groups.<name>`, and Go-template field syntax admits identifier characters
395
+ // only. The pattern IS that constraint; it also excludes quotes, slashes, and
396
+ // dots, so a name needs no escaping anywhere it is spliced.
397
+ const GROUP_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
398
+
399
+ const GROUP_GLYPH_CLOSED = "▸";
400
+ const GROUP_GLYPH_OPEN = "▾";
401
+
402
+ function groupNameSpec(): FieldSpec<string> {
403
+ return {
404
+ required: true,
405
+ json: { type: "string", pattern: GROUP_NAME_RE.source },
406
+ parse: (ctx, path, field, raw) => {
407
+ const v = raw[field];
408
+ if (
409
+ typeof v !== "string" ||
410
+ !GROUP_NAME_RE.test(v) ||
411
+ v === GROUP_CLOSED
412
+ ) {
413
+ ctx.issues.push({
414
+ path: `${path}.${field}`,
415
+ message: `a group "name" must be an identifier (letters, digits, _; not starting with a digit) and not the reserved "${GROUP_CLOSED}", got ${describeValue(v)}`,
416
+ line: findKeyLine(ctx.source, ["root"]),
417
+ });
418
+ return undefined;
419
+ }
420
+ return v;
421
+ },
422
+ };
423
+ }
424
+
425
+ // [LAW:single-enforcer] A group's optional shared `key` is a SessionState key —
426
+ // the same non-empty/slash-free wire shape the action loader's `set` key
427
+ // enforces, restated here because the group synthesizes that `set`.
428
+ function groupKeySpec(): FieldSpec<string> {
429
+ return {
430
+ required: false,
431
+ json: { type: "string", minLength: 1 },
432
+ parse: (ctx, path, field, raw) => {
433
+ const v = raw[field];
434
+ if (v === undefined) return undefined;
435
+ if (typeof v !== "string" || v === "" || v.includes("/")) {
436
+ ctx.issues.push({
437
+ path: `${path}.${field}`,
438
+ message: `a group "key" must be a non-empty, slash-free SessionState key, got ${describeValue(v)}`,
439
+ line: findKeyLine(ctx.source, ["root"]),
440
+ });
441
+ return undefined;
442
+ }
443
+ return v;
444
+ },
445
+ };
446
+ }
447
+
448
+ // [LAW:types-are-the-program] The `group` input record: one declaration carrying
449
+ // everything its synthesized artifacts derive from. `direction` arranges the
450
+ // BODY (the children container) — the toggle row always stacks above it;
451
+ // `key` opts sibling groups into one accordion (shared key ⇒ one open at a
452
+ // time); `open` picks the key's initial state; `bg`/`fg` paint the toggle
453
+ // segment; `when` gates the whole group (toggle included).
454
+ interface GroupNodeInput {
455
+ readonly kind: "group";
456
+ readonly name: string;
457
+ readonly label: string;
458
+ readonly open?: boolean;
459
+ readonly direction?: Direction;
460
+ readonly key?: string;
461
+ readonly bg?: string;
462
+ readonly fg?: string;
463
+ readonly when?: string;
464
+ readonly children: readonly LayoutNode[];
465
+ }
466
+
467
+ // [LAW:no-silent-failure] Reject newlines at the validator boundary — a label
468
+ // with \n or \r would reach escapeTemplateLiteral and produce a Go template
469
+ // string literal with an embedded newline, which go-template-js forbids. Fail
470
+ // loudly here so the loader surfaces the problem before synthesis runs.
471
+ function groupLabelSpec(): FieldSpec<string> {
472
+ return {
473
+ required: true,
474
+ json: { type: "string", pattern: "^[^\\n\\r]*$" },
475
+ parse: (ctx, path, field, raw) => {
476
+ const s = requireString(ctx, path, raw, field);
477
+ if (s === null) return undefined;
478
+ if (/[\n\r]/.test(s)) {
479
+ ctx.issues.push({
480
+ path: `${path}.${field}`,
481
+ message: `${path}.${field}: group label must not contain newlines`,
482
+ line: findKeyLine(ctx.source, [...path.split("."), field]),
483
+ });
484
+ return undefined;
485
+ }
486
+ return s;
487
+ },
488
+ };
489
+ }
490
+
491
+ const GROUP_SCHEMA: RecordSchema<GroupNodeInput> = {
492
+ noun: "layout-node key",
493
+ fields: {
494
+ kind: literalSpec("group"),
495
+ name: groupNameSpec(),
496
+ label: groupLabelSpec(),
497
+ open: optionalBooleanSpec(),
498
+ direction: optionalEnumSpec(DIRECTIONS),
499
+ key: groupKeySpec(),
500
+ bg: optionalStringSpec(),
501
+ fg: optionalStringSpec(),
502
+ when: optionalStringSpec(),
503
+ children: childrenSpec(lazy(() => validateRoot)),
504
+ },
505
+ };
506
+
507
+ // The state key a group toggles: the explicit shared `key` (accordion) or the
508
+ // group's own derived key (independent toggle). One value selects the behavior
509
+ // — no accordion mode [LAW:dataflow-not-control-flow].
510
+ function groupStateKey(g: { name: string; key?: string }): string {
511
+ return g.key ?? GROUP_NS + g.name;
512
+ }
513
+
514
+ // [LAW:one-source-of-truth] Lower a group to the canonical grammar. The toggle
515
+ // segment ref and the body predicate both derive from the group's name — the
516
+ // same name the synthesis names the state var with, so the predicate reads
517
+ // exactly the var the toggle's cycle writes. The body is open exactly when the
518
+ // key holds THIS group's name (a sibling's name or "closed" hides it — the
519
+ // accordion falls out of one key holding one name).
520
+ function lowerGroup(g: GroupNodeInput): LayoutNode {
521
+ const ref = GROUP_NS + g.name;
522
+ return {
523
+ kind: "container",
524
+ direction: "vertical",
525
+ children: [
526
+ { kind: "segment", name: ref },
527
+ {
528
+ kind: "container",
529
+ direction: g.direction ?? "vertical",
530
+ children: g.children,
531
+ when: `{{ eq .${ref} "${g.name}" }}`,
532
+ },
533
+ ],
534
+ ...(g.when !== undefined && { when: g.when }),
535
+ };
536
+ }
537
+
538
+ // Go-template string-literal escaping for the synthesized toggle template — the
539
+ // label is a plain display string (dynamic labels are raw-grammar territory),
540
+ // so backslashes and quotes are the only characters that could break the splice.
541
+ function escapeTemplateLiteral(s: string): string {
542
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
543
+ }
544
+
545
+ function groupIssue(ctx: ValidateCtx, path: string, message: string): void {
546
+ ctx.issues.push({
547
+ path,
548
+ message,
549
+ line: findKeyLine(ctx.source, ["root"]),
550
+ });
551
+ }
552
+
553
+ // [LAW:one-source-of-truth] The synthesis pass: every artifact a group implies,
554
+ // derived from its one declaration and merged into the raw sections, AFTER the
555
+ // user's own sections parsed — so a user name under the reserved namespace is a
556
+ // loud rejection, never a silent overwrite. Runs once per parse, after the root
557
+ // walk collected every group with its tree position.
558
+ //
559
+ // Invariants enforced here (each a load error, never a silent fixup):
560
+ // • group names are unique (they name the synthesized artifacts);
561
+ // • no user-authored variable/action/segment under the reserved namespace;
562
+ // • an ancestor and a descendant group never share a key (one key holds ONE
563
+ // open name, so a same-key chain could not represent "both open" — sibling
564
+ // accordions share keys, nested disclosure nests distinct keys);
565
+ // • at most one group per shared key declares `open: true` (the key's single
566
+ // initial value [LAW:one-source-of-truth]).
567
+ export function synthesizeGroupDecls(
568
+ ctx: ValidateCtx,
569
+ out: Mutable<RawDslConfig>,
570
+ ): void {
571
+ const groups = ctx.groups;
572
+ if (groups.length === 0) return;
573
+
574
+ for (const section of ["variables", "actions", "segments"] as const) {
575
+ for (const name of Object.keys(out[section] ?? {})) {
576
+ if (name.startsWith(GROUP_NS)) {
577
+ groupIssue(
578
+ ctx,
579
+ `${section}.${name}`,
580
+ `"${name}" is in the reserved "${GROUP_NS}" namespace (synthesized by group nodes) — rename it`,
581
+ );
582
+ }
583
+ }
584
+ }
585
+
586
+ const seen = new Set<string>();
587
+ for (const g of groups) {
588
+ if (seen.has(g.name)) {
589
+ groupIssue(
590
+ ctx,
591
+ g.path,
592
+ `duplicate group name "${g.name}" — group names must be unique (they name the synthesized state var, action, and toggle segment)`,
593
+ );
594
+ }
595
+ seen.add(g.name);
596
+ }
597
+
598
+ for (const inner of groups) {
599
+ for (const outer of groups) {
600
+ if (
601
+ inner !== outer &&
602
+ inner.path.startsWith(`${outer.path}.`) &&
603
+ groupStateKey(inner) === groupStateKey(outer)
604
+ ) {
605
+ groupIssue(
606
+ ctx,
607
+ inner.path,
608
+ `group "${inner.name}" shares key "${groupStateKey(inner)}" with its ancestor group "${outer.name}" — a shared key holds ONE open group, so an ancestor and a descendant cannot share one. Sibling accordions share a key; nested groups use distinct keys.`,
609
+ );
610
+ }
611
+ }
612
+ }
613
+
614
+ // [LAW:one-source-of-truth] One initial value per key: the single open
615
+ // group's name, else closed. Every var synthesized on a key carries the SAME
616
+ // default, so two vars reading one key cannot disagree.
617
+ const defaultByKey = new Map<string, string>();
618
+ for (const g of groups) {
619
+ const key = groupStateKey(g);
620
+ if (!defaultByKey.has(key)) defaultByKey.set(key, GROUP_CLOSED);
621
+ if (g.open === true) {
622
+ const prior = defaultByKey.get(key)!;
623
+ if (prior !== GROUP_CLOSED) {
624
+ groupIssue(
625
+ ctx,
626
+ g.path,
627
+ `groups "${prior}" and "${g.name}" share key "${key}" and both declare open: true — a shared key holds one open group; pick one`,
628
+ );
629
+ }
630
+ defaultByKey.set(key, g.name);
631
+ }
632
+ }
633
+
634
+ const variables: Record<string, VariableDecl> = {};
635
+ const actions: Record<string, ActionDecl> = {};
636
+ const segments: Record<string, SegmentDecl> = {};
637
+ for (const g of groups) {
638
+ const name = GROUP_NS + g.name;
639
+ const key = groupStateKey(g);
640
+ const label = escapeTemplateLiteral(g.label);
641
+ // [LAW:dataflow-not-control-flow] Depth is a value derivable from the paths
642
+ // already in ctx.groups — no extra threading. Strict-prefix count gives
643
+ // nesting depth; the indent embeds as a string constant in the template.
644
+ const depth = groups.filter(
645
+ (other) => other !== g && g.path.startsWith(other.path + "."),
646
+ ).length;
647
+ const indent = " ".repeat(depth);
648
+ variables[name] = {
649
+ kind: "state",
650
+ key,
651
+ default: defaultByKey.get(key)!,
652
+ };
653
+ // Members are ordered default-state-first ("closed" first): an unset or
654
+ // sibling-held key counts as the first member, so the toggle renders ▸ and
655
+ // clicks to its own name — expand, auto-closing the sibling on a shared key.
656
+ actions[name] = { set: key, cycle: [GROUP_CLOSED, g.name] };
657
+ segments[name] = {
658
+ template: `{{ action "${name}" "${indent}${GROUP_GLYPH_CLOSED} ${label}" "${indent}${GROUP_GLYPH_OPEN} ${label}" }}`,
659
+ ...(g.bg !== undefined && { bg: g.bg }),
660
+ ...(g.fg !== undefined && { fg: g.fg }),
661
+ };
662
+ }
663
+ out.variables = { ...(out.variables ?? {}), ...variables };
664
+ out.actions = { ...(out.actions ?? {}), ...actions };
665
+ out.segments = { ...(out.segments ?? {}), ...segments };
666
+ }
667
+
668
+ // ─── Schema emit ─────────────────────────────────────────────────────────────
669
+
670
+ // [LAW:one-source-of-truth] The LayoutNode definition: the anyOf of ALL arms
671
+ // `validateRoot` dispatches over — kind-based (container / segment / group) and
672
+ // terse A-grammar (bare string, seg-arm, h-arm, v-arm) — each derived from the
673
+ // SAME schema the validator interprets. The `kind` const and the unique required
674
+ // key keep arms disjoint; the container/h/v children `$ref` back here, closing
675
+ // the recursion. `{ type: "string" }` covers the bare-string segment-ref form.
676
+ export function layoutNodeJson(): JsonNode {
677
+ return {
678
+ anyOf: [
679
+ { type: "string" },
680
+ recordJson(CONTAINER_SCHEMA),
681
+ recordJson(SEGMENT_NODE_SCHEMA),
682
+ recordJson(GROUP_SCHEMA),
683
+ recordJson(SEG_ARM_SCHEMA),
684
+ recordJson(H_ARM_SCHEMA),
685
+ recordJson(V_ARM_SCHEMA),
686
+ ],
687
+ };
688
+ }