@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.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- 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
|
+
}
|