@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,265 @@
1
+ // [LAW:one-type-per-behavior] Three primitives, three concerns:
2
+ //
3
+ // parseDslConfig (text → RawDslConfig)
4
+ // JSON5 syntax + per-record structural validation. Rejects the removed
5
+ // `layout:` key and `kind:"cells"` node with migration-pointing errors.
6
+ // Throws ConfigError on syntax / structural problems.
7
+ //
8
+ // mergeWithDefault (RawDslConfig + DslConfig → DslConfig)
9
+ // Cascade: shallow merge globals fields, by-name merge variables and
10
+ // segments, wholesale root replacement when present. Pure function.
11
+ //
12
+ // validateConfig (DslConfig → ValidatedConfig)
13
+ // Cross-references + cycle detection on the merged shape. Sole producer
14
+ // of ValidatedConfig. Throws ConfigError on cross-ref / cycle problems.
15
+ //
16
+ // loadConfig (path|null → DslConfig) wires parse+merge for the daemon's
17
+ // production path. validateConfig finishes the chain.
18
+ //
19
+ // [LAW:dataflow-not-control-flow] Validation passes accumulate issues into
20
+ // a list; consumers see every problem at once (compiler-style).
21
+ //
22
+ // This file is the pipeline orchestrator + the public barrel. Each validation
23
+ // concern lives in its own `loader/` module (split by change-reason); the
24
+ // re-exports below keep the import surface stable for every consumer.
25
+
26
+ import fs from "node:fs";
27
+ import JSON5 from "json5";
28
+ import {
29
+ type DslConfig,
30
+ type RawDslConfig,
31
+ type ValidatedConfig,
32
+ } from "./dsl-types.js";
33
+ import { DEFAULT_DSL_CONFIG } from "./default-dsl-config.js";
34
+ import { listResolvablePaletteNames } from "../themes/policy.js";
35
+ import {
36
+ ConfigError,
37
+ findKeyLine,
38
+ type ConfigIssue,
39
+ } from "./loader/diagnostics.js";
40
+ import {
41
+ describeType,
42
+ isPlainObject,
43
+ type Mutable,
44
+ type ValidateCtx,
45
+ } from "./loader/validate-core.js";
46
+ import { mergeWithDefault } from "./loader/merge.js";
47
+ import { validateGlobals } from "./loader/globals.js";
48
+ import { validateVariables } from "./loader/variables.js";
49
+ import { validateSegments } from "./loader/segments.js";
50
+ import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
51
+ import { validateActions } from "./loader/actions.js";
52
+ import { validateHelpers } from "./loader/helpers.js";
53
+ import { validateCrossReferences } from "./loader/cross-ref.js";
54
+ import { validateNoCycles } from "./loader/cycles.js";
55
+
56
+ // ─── Public barrel ───────────────────────────────────────────────────────────
57
+ // [LAW:locality-or-seam] Consumers import from `dsl-loader`; the internal split
58
+ // is invisible to them. Moving a symbol between loader/ modules never touches a
59
+ // callsite as long as it stays re-exported here.
60
+
61
+ export { ConfigError, findKeyLine } from "./loader/diagnostics.js";
62
+ export type { ConfigIssue } from "./loader/diagnostics.js";
63
+ export {
64
+ expandHome,
65
+ dslConfigCandidatePaths,
66
+ resolveDslConfigPath,
67
+ detectConfigCollisions,
68
+ } from "./loader/discovery.js";
69
+ export { mergeWithDefault } from "./loader/merge.js";
70
+ export {
71
+ extractTemplateRefs,
72
+ extractActionRefs,
73
+ extractPickerRefs,
74
+ } from "./loader/refs.js";
75
+
76
+ // ─── Three-stage pipeline ────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Load a JSON5 DSL config file from disk and merge it with the bundled
80
+ * default. Returns the effective DslConfig AND the raw source text.
81
+ *
82
+ * `path = null` means "no user file exists" — returns the default unchanged
83
+ * (uniform merge against an empty raw, which is deep-equal to the default) and
84
+ * an empty source. No consumer branches on file presence; that branch lives
85
+ * inside loadConfig exactly once.
86
+ *
87
+ * [LAW:one-source-of-truth] The source is returned alongside the config so the
88
+ * caller can hand it to validateConfig — cross-ref diagnostics (line numbers,
89
+ * the authored-surface discriminator) are derived from it, and the file is read
90
+ * exactly once here rather than re-read downstream.
91
+ *
92
+ * Throws ConfigError on JSON5 syntax / structural / per-record validation
93
+ * failures. Cross-references and cycles are validateConfig()'s job.
94
+ *
95
+ * [LAW:dataflow-not-control-flow] One function, one branch, same operations
96
+ * each call.
97
+ */
98
+ export function loadConfig(
99
+ path: string | null,
100
+ dflt: DslConfig = DEFAULT_DSL_CONFIG,
101
+ allowedPalettes?: ReadonlySet<string>,
102
+ ): { config: DslConfig; source: string } {
103
+ const source = path === null ? "" : fs.readFileSync(path, "utf-8");
104
+ const raw: RawDslConfig =
105
+ path === null ? {} : parseDslConfig(path, source, allowedPalettes);
106
+ return { config: mergeWithDefault(raw, dflt), source };
107
+ }
108
+
109
+ /**
110
+ * Promote a merged DslConfig to a ValidatedConfig by running cross-references
111
+ * and cycle detection. Sole producer of ValidatedConfig in the codebase — the
112
+ * phantom brand makes "the renderer never receives an unvalidated config" a
113
+ * compile-time invariant, not a runtime convention.
114
+ *
115
+ * Throws ConfigError aggregating every issue.
116
+ *
117
+ * [LAW:single-enforcer] One cast site, here, exclusive.
118
+ */
119
+ export function validateConfig(
120
+ config: DslConfig,
121
+ filePath = "<config>",
122
+ source = "",
123
+ allowedPalettes: ReadonlySet<string> = new Set(listResolvablePaletteNames()),
124
+ ): ValidatedConfig {
125
+ const issues: ConfigIssue[] = [];
126
+ const ctx: ValidateCtx = { source, issues, allowedPalettes, groups: [] };
127
+ validateCrossReferences(ctx, config);
128
+ validateNoCycles(ctx, config);
129
+ if (issues.length > 0) {
130
+ throw new ConfigError(filePath, issues);
131
+ }
132
+ return config as ValidatedConfig;
133
+ }
134
+
135
+ /**
136
+ * Parse a JSON5 DSL config source into a RawDslConfig. JSON5 syntax + per-
137
+ * record structural validation. Cross-references and cycles are NOT checked
138
+ * here — they belong to validateConfig, which runs on the merged shape.
139
+ *
140
+ * Returned shape preserves absence: top-level keys are optional in RawDslConfig.
141
+ *
142
+ * `allowedPalettes` is the set of palette names a `palette:` field may name.
143
+ * It defaults to every name that resolves to a concrete Palette, so production
144
+ * always validates loudly against the real registry. Tests inject a custom set
145
+ * to exercise validation without depending on registry contents.
146
+ */
147
+ export function parseDslConfig(
148
+ filePath: string,
149
+ source: string,
150
+ allowedPalettes: ReadonlySet<string> = new Set(listResolvablePaletteNames()),
151
+ ): RawDslConfig {
152
+ // ── Stage 1: JSON5 syntax. A parse error here is single, immediate, and
153
+ // carries line/col from the json5 package — no point continuing to other
154
+ // passes that need a parsed structure to inspect.
155
+ const raw = parseJson5OrThrow(filePath, source);
156
+
157
+ const issues: ConfigIssue[] = [];
158
+ const ctx: ValidateCtx = { source, issues, allowedPalettes, groups: [] };
159
+
160
+ // ── Stage 2: top-level shape + per-record shape. Absence survives as
161
+ // `undefined` in the returned RawDslConfig.
162
+ if (!isPlainObject(raw)) {
163
+ throw new ConfigError(filePath, [
164
+ {
165
+ path: "",
166
+ message: `Config root must be an object, got ${describeType(raw)}`,
167
+ },
168
+ ]);
169
+ }
170
+
171
+ const topLevel = validateTopLevel(ctx, raw);
172
+
173
+ if (issues.length > 0) {
174
+ throw new ConfigError(filePath, issues);
175
+ }
176
+
177
+ return topLevel;
178
+ }
179
+
180
+ // ─── Internals ───────────────────────────────────────────────────────────────
181
+
182
+ interface Json5Error extends Error {
183
+ lineNumber?: number;
184
+ columnNumber?: number;
185
+ }
186
+
187
+ function parseJson5OrThrow(filePath: string, source: string): unknown {
188
+ try {
189
+ return JSON5.parse(source);
190
+ } catch (err) {
191
+ const e = err as Json5Error;
192
+ throw new ConfigError(filePath, [
193
+ {
194
+ path: "",
195
+ message: `JSON5 syntax error: ${e.message}`,
196
+ line: e.lineNumber,
197
+ col: e.columnNumber,
198
+ },
199
+ ]);
200
+ }
201
+ }
202
+
203
+ // [LAW:types-are-the-program] Returns RawDslConfig — absence of a top-level
204
+ // key survives the parse as `undefined`, distinct from explicit empty. The
205
+ // merge step downstream decides what "absent" means policy-wise (currently:
206
+ // inherit from default).
207
+ function validateTopLevel(
208
+ ctx: ValidateCtx,
209
+ raw: Record<string, unknown>,
210
+ ): RawDslConfig {
211
+ for (const key of Object.keys(raw)) {
212
+ if (!TOP_LEVEL_KEYS.has(key)) {
213
+ ctx.issues.push({
214
+ path: key,
215
+ message: `Unknown top-level key "${key}". Expected one of: ${[...TOP_LEVEL_KEYS].join(", ")}`,
216
+ line: findKeyLine(ctx.source, [key]),
217
+ });
218
+ }
219
+ }
220
+
221
+ const out: Mutable<RawDslConfig> = {};
222
+ if (raw.globals !== undefined)
223
+ out.globals = validateGlobals(ctx, raw.globals);
224
+ if (raw.variables !== undefined)
225
+ out.variables = validateVariables(ctx, "variables", raw.variables);
226
+ if (raw.segments !== undefined)
227
+ out.segments = validateSegments(ctx, raw.segments);
228
+ // [LAW:no-silent-failure] `layout:` was removed in 2de.19. Reject loudly with
229
+ // a migration hint so the author knows exactly how to rewrite their config.
230
+ if (raw.layout !== undefined) {
231
+ ctx.issues.push({
232
+ path: "layout",
233
+ message:
234
+ `"layout" is no longer supported — use "root" with the A-grammar instead.\n` +
235
+ ` Replace: layout: [["seg1", "seg2"], ["seg3"]]\n` +
236
+ ` With: root: { v: [{ h: ["seg1", "seg2"] }, "seg3"] }\n` +
237
+ ` Single-row example: root: { h: ["seg1", "seg2"] }`,
238
+ line: findKeyLine(ctx.source, ["layout"]),
239
+ });
240
+ }
241
+ if (raw.root !== undefined) out.root = validateRoot(ctx, "root", raw.root);
242
+ if (raw.actions !== undefined)
243
+ out.actions = validateActions(ctx, raw.actions);
244
+ if (raw.helpers !== undefined)
245
+ out.helpers = validateHelpers(ctx, raw.helpers);
246
+ // [LAW:one-source-of-truth] Group sugar synthesis runs AFTER every section
247
+ // parsed: each group collected during the root walk emits its state var +
248
+ // cycle action + toggle segment into the raw sections (so they merge over the
249
+ // default and cross-ref like any user declaration), and user names under the
250
+ // reserved namespace are rejected against the fully-parsed sections.
251
+ synthesizeGroupDecls(ctx, out);
252
+ return out;
253
+ }
254
+
255
+ // [LAW:no-silent-failure] `layout` is intentionally absent — a config that
256
+ // writes it gets an explicit migration error, not an "unknown key" message.
257
+ const TOP_LEVEL_KEYS = new Set([
258
+ "globals",
259
+ "variables",
260
+ "segments",
261
+ "layout",
262
+ "root",
263
+ "actions",
264
+ "helpers",
265
+ ]);
@@ -0,0 +1,425 @@
1
+ // [LAW:types-are-the-program] DslConfig is the strongest theorem we can write
2
+ // about a validated config: every legal config is representable, every illegal
3
+ // one is not. The loader is the proof — its body either narrows `unknown` to
4
+ // `DslConfig` or throws ConfigError. Downstream consumers receive a DslConfig
5
+ // and are free to assume invariants (closed source-kind set, exactly-one cache
6
+ // key, no dangling cross-refs, no template cycles) without re-checking.
7
+ //
8
+ // [LAW:one-source-of-truth] These shapes are the JSON-shape mirror of the
9
+ // var-system's runtime types (`CachePolicy`, `ShellOptions`, etc. in
10
+ // src/var-system/sources.ts). The loader is the single point that translates
11
+ // between the two; no other module should re-derive these shapes.
12
+
13
+ // [LAW:one-way-deps] The action schema lives in its own leaf module; DslConfig
14
+ // references it here. The dependency is one-way (this file → action.ts), never
15
+ // the reverse, so that shape can be lifted out without a cycle.
16
+ import type { ActionDecl } from "./action.js";
17
+
18
+ // [LAW:types-are-the-program] Three stages, three names.
19
+ //
20
+ // RawDslConfig — the user-file shape. Every top-level key is optional
21
+ // because "user didn't write this" is a representable,
22
+ // distinct state from "user wrote an explicit empty."
23
+ // Internal to the loader module; downstream consumers
24
+ // never see it.
25
+ //
26
+ // DslConfig — the effective shape: the user's deltas merged on top
27
+ // of DEFAULT_DSL_CONFIG. Every top-level key is required.
28
+ // Output of `loadConfig`. Cross-refs and cycles have NOT
29
+ // yet been checked at this stage.
30
+ //
31
+ // ValidatedConfig — DslConfig + a phantom brand proving validateConfig()
32
+ // has run. The renderer accepts only this type, so the
33
+ // compiler structurally enforces "no unvalidated config
34
+ // can reach rendering." The brand is module-scoped via
35
+ // `unique symbol`, so the only construction site is
36
+ // `validateConfig` itself.
37
+ // [LAW:types-are-the-program] The recursive layout substrate collapses to
38
+ // exactly two kinds: a `segment` leaf (a ref into the named `segments` block —
39
+ // THE unit of rendering, a single template that IS its content) or a
40
+ // `container` whose `direction` is DATA that decides how its children map onto
41
+ // the 2D plane. Both the bar and (a later child's) menu are projections of this
42
+ // one tree — they differ only in `direction`, not in code path
43
+ // [LAW:dataflow-not-control-flow].
44
+ //
45
+ // [LAW:types-are-the-program] `Direction` carries the projection a container
46
+ // applies to its child blocks as DATA. `vertical` STACKS them (concat the
47
+ // children's line-lists); `horizontal` ZIPS them (per row, the children's cells
48
+ // concatenate into one strip, so the powerline joiner caps ACROSS the seam —
49
+ // abut is never valid). `outline` (a later child's menu) is NOT in the union
50
+ // yet — it joins as a new arm only when its renderer exists, so the union stays
51
+ // the strongest theorem that is still TRUE, with no representable-but-
52
+ // unrenderable direction.
53
+ // [LAW:one-source-of-truth] The runtime list and the type derive from one
54
+ // declaration; the loader validates a container's `direction` against this set,
55
+ // and renderDsl's projection switch is exhaustive over it (adding an arm here
56
+ // forces a matching render arm).
57
+ export const DIRECTIONS = ["vertical", "horizontal"] as const;
58
+ export type Direction = (typeof DIRECTIONS)[number];
59
+
60
+ // [LAW:one-type-per-behavior] THE unit of rendering: a ref into the named
61
+ // `segments` block. A segment IS a single template (text, state-driven display,
62
+ // clickable regions — whatever the template produces); there is no `inline` /
63
+ // `stepper` / `picker` node kind, because "make a node flexible enough for
64
+ // whatever" = one template expresses anything. A segment renders to ONE strip
65
+ // item; the powerline joiner joins items, never inside one. A horizontal run of
66
+ // segments is spelled `{ h: ["seg1", "seg2"] }` in the A-grammar.
67
+ export interface SegmentNode {
68
+ readonly kind: "segment";
69
+ // A name into the `segments` block. The segment's own template/palette/`when`
70
+ // live on its SegmentDecl, not here; this node is purely the tree position.
71
+ readonly name: string;
72
+ // [LAW:dataflow-not-control-flow] Absent `when` ≡ always-rendered. A node-level
73
+ // predicate, ANDed with the segment-decl's own `when` at render.
74
+ readonly when?: string;
75
+ }
76
+
77
+ export interface ContainerNode {
78
+ readonly kind: "container";
79
+ readonly direction: Direction;
80
+ readonly children: readonly LayoutNode[];
81
+ // A container's `when` gates the whole subtree: a hidden container emits no
82
+ // lines, but its descendants are still walked so per-segment hue indices stay
83
+ // positionally stable.
84
+ readonly when?: string;
85
+ }
86
+
87
+ export type LayoutNode = ContainerNode | SegmentNode;
88
+
89
+ // [LAW:types-are-the-program] The `group` SUGAR as collected at parse — an
90
+ // INPUT-only shape, never a canonical LayoutNode kind: arranging + gating are
91
+ // behaviors `container` already has, so "group" may only be a spelling. The
92
+ // loader lowers each group to container/segment nodes and SYNTHESIZES its state
93
+ // var + cycle action + toggle segment under the reserved `groups.` namespace
94
+ // (one declaration; every derived artifact single-sourced from it
95
+ // [LAW:one-source-of-truth]). `path` records the node's tree position so the
96
+ // nesting invariant (an ancestor and a descendant must not share a state key)
97
+ // is checkable after the walk.
98
+ export interface GroupSugarDecl {
99
+ readonly name: string;
100
+ readonly label: string;
101
+ readonly open?: boolean;
102
+ readonly direction?: Direction;
103
+ readonly key?: string;
104
+ readonly bg?: string;
105
+ readonly fg?: string;
106
+ readonly when?: string;
107
+ readonly path: string;
108
+ }
109
+
110
+ // [LAW:single-enforcer] THE one pre-order walk over a node tree. Every consumer
111
+ // that needs "which segments / which `when` predicates does this layout name"
112
+ // (the reachability closure, the debug dump, the cross-ref validator) iterates
113
+ // this — none re-recurses the tree itself.
114
+ export function* walkNodes(node: LayoutNode): IterableIterator<LayoutNode> {
115
+ yield node;
116
+ if (node.kind === "container") {
117
+ for (const child of node.children) yield* walkNodes(child);
118
+ }
119
+ }
120
+
121
+ export interface RawDslConfig {
122
+ readonly globals?: Partial<Globals>;
123
+ readonly variables?: Readonly<Record<string, VariableDecl>>;
124
+ readonly segments?: Readonly<Record<string, SegmentDecl>>;
125
+ readonly root?: LayoutNode;
126
+ readonly actions?: Readonly<Record<string, ActionDecl>>;
127
+ // [LAW:single-enforcer] Config-level shared helper templates: name → Go-template
128
+ // body. Each compiles to one `{{ define "name" }}body{{ end }}` block, and the
129
+ // whole set into a single output-neutral preamble prepended to every template
130
+ // this config parses — so a formatter (`{{ template "formatCost" .x }}`) is
131
+ // defined ONCE and callable from any segment/predicate, never re-inlined per
132
+ // segment. Absent ≡ no helpers; merges by-name (user overrides a helper).
133
+ readonly helpers?: Readonly<Record<string, string>>;
134
+ }
135
+
136
+ export interface DslConfig {
137
+ readonly globals: Globals;
138
+ readonly variables: Readonly<Record<string, VariableDecl>>;
139
+ readonly segments: Readonly<Record<string, SegmentDecl>>;
140
+ // [LAW:one-source-of-truth] The SINGLE canonical layout representation authored
141
+ // via the A-grammar (seg/h/v node arms, group sugar). No legacy sugar reaches
142
+ // this field; the loader rejects `layout:` and `kind:"cells"` with migration errors.
143
+ readonly root: LayoutNode;
144
+ // [LAW:locality-or-seam] The named seam between click BEHAVIOR and the
145
+ // clickable REPRESENTATION. Each entry is a statically-declared effect a
146
+ // segment template binds a region to via `{{ action "name" … }}`. The
147
+ // writable-key gate derives from this table (deriveActionValidators), so a
148
+ // template cannot smuggle an un-gated write. Empty when no config declares
149
+ // actions — an absent `actions` key merges to `{}`.
150
+ readonly actions: Readonly<Record<string, ActionDecl>>;
151
+ // [LAW:single-enforcer] The effective helper set: a name → template-body map
152
+ // compiled to a defines-preamble at registerDslConfig. Empty when no config
153
+ // declares helpers — an absent `helpers` key merges to `{}` (same cascade as
154
+ // actions). The single definition site for each formatter/transform a template
155
+ // calls via `{{ template "name" .arg }}`.
156
+ readonly helpers: Readonly<Record<string, string>>;
157
+ }
158
+
159
+ // [LAW:single-enforcer] The brand symbol is `unique` and module-private —
160
+ // nothing outside this file can construct a value carrying it. The only
161
+ // production-path producer is validateConfig() in dsl-loader.ts (one
162
+ // callsite of `config as ValidatedConfig`). Renderer signatures require
163
+ // ValidatedConfig; the type system therefore proves the validation step
164
+ // ran before any render path consumed the config.
165
+ declare const __validated: unique symbol;
166
+ export type ValidatedConfig = DslConfig & {
167
+ readonly [__validated]: true;
168
+ };
169
+
170
+ export interface Globals {
171
+ readonly default_bg?: string;
172
+ readonly default_fg?: string;
173
+ readonly default_empty_value?: string;
174
+ readonly default_separator?: string;
175
+ readonly default_truncate_marker?: string;
176
+ // [LAW:one-source-of-truth] A palette NAME, not a resolved Palette: DslConfig
177
+ // is the JSON-shape mirror, so the name is the authoritative datum and the
178
+ // renderer owns name→Palette resolution. The config default for the base
179
+ // theme; the daemon resolves the live base per render as
180
+ // `sessionState.theme ?? globals.palette ?? default`, and a per-segment
181
+ // `palette` is an explicit override that ignores the session theme.
182
+ readonly palette?: string;
183
+ }
184
+
185
+ // [LAW:one-type-per-behavior] One discriminated union covers every source
186
+ // kind. Adding a new kind = code change here + matching runtime support in
187
+ // var-system. There is no "extension" path that bypasses this list.
188
+ export type VariableDecl =
189
+ | LiteralVarDecl
190
+ | InputVarDecl
191
+ | EnvVarDecl
192
+ | FileVarDecl
193
+ | ShellVarDecl
194
+ | TemplateVarDecl
195
+ | TimeVarDecl
196
+ | GitVarDecl
197
+ | StateVarDecl;
198
+
199
+ export interface LiteralVarDecl {
200
+ readonly kind: "literal";
201
+ readonly value: string | number | boolean;
202
+ readonly default?: string;
203
+ }
204
+
205
+ // [LAW:types-are-the-program] `type` carries the runtime kind of the value at
206
+ // the resolved payload path. Number/bool are needed for the usage/cost/today
207
+ // family — token counts, cost amounts, percentages — whose formatters
208
+ // (`formatCost`, `formatTokens`, `round`, `budgetStatus`) take numeric inputs.
209
+ // Absent `type` defaults to "string" at the loader, preserving the historical
210
+ // behavior of every existing declaration. The default value's literal type
211
+ // must match the declared type — a number default on a string-typed input
212
+ // (or vice versa) is rejected at load time, not at first render.
213
+ export interface InputVarDecl {
214
+ readonly kind: "input";
215
+ readonly path: string;
216
+ readonly type?: "string" | "number" | "boolean";
217
+ readonly default?: string | number | boolean;
218
+ }
219
+
220
+ export interface EnvVarDecl {
221
+ readonly kind: "env";
222
+ readonly name: string;
223
+ readonly default?: string;
224
+ }
225
+
226
+ export interface FileVarDecl {
227
+ readonly kind: "file";
228
+ readonly path: string;
229
+ readonly readMode?: "whole" | "first-line";
230
+ readonly regex?: string;
231
+ readonly cache: CacheDecl;
232
+ readonly default?: string;
233
+ }
234
+
235
+ export interface ShellVarDecl {
236
+ readonly kind: "shell";
237
+ readonly command: string;
238
+ readonly regex?: string;
239
+ readonly cache: CacheDecl;
240
+ readonly default?: string;
241
+ }
242
+
243
+ export interface TemplateVarDecl {
244
+ readonly kind: "template";
245
+ readonly template: string;
246
+ readonly cache?: CacheDecl;
247
+ readonly default?: string;
248
+ }
249
+
250
+ // [LAW:types-are-the-program] Time vars refresh on a clock — ttl is the only
251
+ // cache form the runtime honors (declareTime always registers a TTL timer).
252
+ // The loader rejects the other CacheDecl arms at load, so past that boundary
253
+ // a non-ttl cache on a time var is unrepresentable, not silently coerced.
254
+ export interface TimeVarDecl {
255
+ readonly kind: "time";
256
+ readonly layout: string;
257
+ readonly cache?: TtlCacheDecl;
258
+ readonly default?: string;
259
+ }
260
+
261
+ export interface GitVarDecl {
262
+ readonly kind: "git";
263
+ readonly field: GitField;
264
+ readonly cache: CacheDecl;
265
+ readonly default?: string;
266
+ }
267
+
268
+ // [LAW:one-source-of-truth] A `state` variable reads through to the daemon's
269
+ // SessionState (the canonical store for per-session toggles, random picks,
270
+ // click-mutated values). Reactivity is wired by SessionState's internal MobX
271
+ // atom — a click verb that writes into SessionState invalidates this
272
+ // variable's downstream computeds automatically. Persistence rides for free
273
+ // on SessionState's disk backing.
274
+ //
275
+ // The session id is resolved from the conventional `session.id` variable —
276
+ // that name is the canonical anchor for "which session am I in," declared
277
+ // once by DSL configs as an input variable carrying hook_data.session_id.
278
+ // [LAW:no-mode-explosion] No per-decl override knob: a single canonical
279
+ // session-id source keeps every state var's resolution uniform and removes
280
+ // an axis along which configs could drift from each other.
281
+ export interface StateVarDecl {
282
+ readonly kind: "state";
283
+ readonly key: string;
284
+ readonly default?: string;
285
+ }
286
+
287
+ export type GitField =
288
+ | "branch"
289
+ | "sha"
290
+ | "dirty"
291
+ | "ahead"
292
+ | "behind"
293
+ | "stash";
294
+
295
+ // [LAW:dataflow-not-control-flow] The discriminator is "which key is present"
296
+ // in the user's JSON — not a `kind` field. Encoded as a 5-arm union so the
297
+ // type system enforces "exactly one of these." The loader validates the
298
+ // runtime invariant (one and only one); the type then carries it forward.
299
+ export type CacheDecl =
300
+ | TtlCacheDecl
301
+ | { readonly watch_file: string }
302
+ | { readonly depends_on: readonly string[] }
303
+ | { readonly key: string }
304
+ | { readonly never: true };
305
+
306
+ // [LAW:one-source-of-truth] The ttl arm named once, so the kinds that honor
307
+ // only a refresh interval (time) reference the same member the full vocabulary
308
+ // is composed from — narrowing is a subset, never a parallel shape.
309
+ export interface TtlCacheDecl {
310
+ readonly ttl: string;
311
+ }
312
+
313
+ export const CACHE_KEYS = [
314
+ "ttl",
315
+ "watch_file",
316
+ "depends_on",
317
+ "key",
318
+ "never",
319
+ ] as const;
320
+ export type CacheKey = (typeof CACHE_KEYS)[number];
321
+
322
+ export const SOURCE_KINDS = [
323
+ "literal",
324
+ "input",
325
+ "env",
326
+ "file",
327
+ "shell",
328
+ "template",
329
+ "time",
330
+ "git",
331
+ "state",
332
+ ] as const;
333
+ export type SourceKind = (typeof SOURCE_KINDS)[number];
334
+
335
+ // [LAW:one-source-of-truth] The "which kinds have a cache field" predicate
336
+ // lives here once. The loader's cross-ref and cycle validators narrow via
337
+ // this guard instead of repeating the kind list (`!== "literal" && !==
338
+ // "input" && ...`) at every site — adding a new no-cache kind only requires
339
+ // updating the union and this guard.
340
+ export type VariableDeclWithCache =
341
+ | FileVarDecl
342
+ | ShellVarDecl
343
+ | TemplateVarDecl
344
+ | TimeVarDecl
345
+ | GitVarDecl;
346
+
347
+ export function hasCacheField(v: VariableDecl): v is VariableDeclWithCache {
348
+ return (
349
+ v.kind !== "literal" &&
350
+ v.kind !== "input" &&
351
+ v.kind !== "env" &&
352
+ v.kind !== "state"
353
+ );
354
+ }
355
+
356
+ export const GIT_FIELDS: readonly GitField[] = [
357
+ "branch",
358
+ "sha",
359
+ "dirty",
360
+ "ahead",
361
+ "behind",
362
+ "stash",
363
+ ];
364
+
365
+ // Source kinds where the user MUST declare a cache policy (no sensible default).
366
+ // Aligns with the proposal's cache-invalidation table.
367
+ export const SOURCES_REQUIRING_CACHE: readonly SourceKind[] = [
368
+ "file",
369
+ "shell",
370
+ "git",
371
+ ];
372
+
373
+ export interface SegmentDecl {
374
+ readonly template: string;
375
+ readonly width?: "auto" | number;
376
+ readonly justify?: JustifyMode;
377
+ readonly truncate?: TruncateMode;
378
+ readonly bg?: string;
379
+ readonly fg?: string;
380
+ readonly when?: string;
381
+ // [LAW:one-source-of-truth] Per-segment palette override (a NAME). Overrides
382
+ // globals.palette for this segment only; undefined = inherit the cascade base.
383
+ readonly palette?: string;
384
+ // Per-segment vars sub-block — lives in the same global MobX store at
385
+ // runtime under the namespaced key `<segment>.<var>`. Templates reference a
386
+ // segment local ONLY via that namespaced form (`.<segment>.local`), from any
387
+ // segment including the owning one; the loader rejects bare refs at load
388
+ // with a diagnostic naming the namespaced form. [LAW:one-source-of-truth]
389
+ readonly vars?: Readonly<Record<string, VariableDecl>>;
390
+ }
391
+
392
+ export type JustifyMode = "left" | "center" | "right";
393
+ export type TruncateMode = "right" | "left" | "middle";
394
+
395
+ export const JUSTIFY_MODES: readonly JustifyMode[] = [
396
+ "left",
397
+ "center",
398
+ "right",
399
+ ];
400
+ export const TRUNCATE_MODES: readonly TruncateMode[] = [
401
+ "right",
402
+ "left",
403
+ "middle",
404
+ ];
405
+
406
+ // ─── Conventional render-time variable names ─────────────────────────────────
407
+ //
408
+ // [LAW:one-source-of-truth] These are not widget types (those live in
409
+ // `./action.ts`); they are the conventional variable NAMES the renderer and the
410
+ // picker agree on. Kept here, with the other render/config conventions.
411
+
412
+ // [LAW:one-source-of-truth] The conventional variable a picker paginates against
413
+ // — the usable terminal width renderDsl injects each render. One name shared by
414
+ // the declaration (default config) and the picker's read, so they cannot drift.
415
+ export const TERM_COLS_VAR = "term.cols";
416
+
417
+ // [LAW:one-source-of-truth] The conventional variable per-segment hue rotation
418
+ // reads. hueStep is NOT a globals field (that would be a second source for a
419
+ // render-time value); it is a value in the store like every other render input.
420
+ // A config declares this variable — as a `state` var so a stepper can drive it
421
+ // live (session value over the declared default, the same session-over-default
422
+ // the theme uses), or as any kind for a fixed value. renderDsl reads it through
423
+ // this one name; a bounded stepper action writes the SessionState key it reads.
424
+ // Absent ≡ no rotation (step 0) — the degenerate case, not a special branch.
425
+ export const HUE_STEP_VAR = "hue.step";