@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,90 @@
1
+ // [LAW:dataflow-not-control-flow] The fragment walk is unconditional: every
2
+ // fragment is visited; style.link (a value on the fragment) decides whether
3
+ // it becomes its own cell or coalesces with neighbours. No branching on
4
+ // "are there any cells" — variability lives entirely in the data.
5
+ //
6
+ // [LAW:one-type-per-behavior] Cells are RichText. There is no parallel
7
+ // "cell" type with a single-bg invariant: rich-js's joiner protocol asks
8
+ // each item only for its edge style, so the interior can vary freely.
9
+ // What was previously expressed as "split this run into N cells at bg
10
+ // boundaries" or "lift the modal style to cell-level so parts survive a
11
+ // slice" is now structurally impossible to need — RichText carries per-
12
+ // character styling via spans, and every layout op (truncate / align /
13
+ // pad / slice) preserves spans by construction.
14
+
15
+ import { RichText } from "@promptctl/rich-js";
16
+ import type { Style } from "@promptctl/rich-js";
17
+
18
+ /**
19
+ * Convert template-engine fragments (`RichText[]`) into Strip cells
20
+ * (`RichText[]`), splitting at OSC-8 link boundaries so each clickable
21
+ * region is its own cell. Non-link runs coalesce into one cell whose
22
+ * interior styling is carried as spans.
23
+ *
24
+ * `baseStyle` is the segment-level default (resolved bg + fg). It becomes
25
+ * the cell's wrapping style so segment-wide bg+fg cascade across every
26
+ * character, and per-fragment fg overlays land as spans on top.
27
+ *
28
+ * [LAW:single-enforcer] The only mapper from template fragments to Strip
29
+ * cells. Callers do not assemble cells by hand.
30
+ */
31
+ export function fragmentsToCells(
32
+ fragments: RichText[],
33
+ baseStyle?: Style,
34
+ ): RichText[] {
35
+ const cells: RichText[] = [];
36
+ let group: RichText[] = [];
37
+
38
+ const flush = () => {
39
+ if (!group.length) return;
40
+ const cell = buildCell(group, baseStyle);
41
+ if (cell.plain.length > 0) cells.push(cell);
42
+ group = [];
43
+ };
44
+
45
+ for (const frag of fragments) {
46
+ if (frag.style.link) {
47
+ flush();
48
+ const cell = buildCell([frag], baseStyle);
49
+ if (cell.plain.length > 0) cells.push(cell);
50
+ } else {
51
+ group.push(frag);
52
+ }
53
+ }
54
+ flush();
55
+
56
+ return cells;
57
+ }
58
+
59
+ function buildCell(fragments: RichText[], baseStyle?: Style): RichText {
60
+ // [LAW:types-are-the-program] Each fragment carries its own style (and
61
+ // possibly spans). We merge baseStyle UNDER each fragment's style before
62
+ // assembling so the segment-wide default flows through every character,
63
+ // with the fragment's own style winning on overlap. That merged style
64
+ // then lands as a span on the assembled RichText, so per-fragment styles
65
+ // are addressable as overlays.
66
+ const layered =
67
+ baseStyle !== undefined && !baseStyle.isNull
68
+ ? fragments.map((f) => withBaseStyle(f, baseStyle))
69
+ : fragments;
70
+ const cell = RichText.fromFragments(layered);
71
+ cell.end = "";
72
+ cell.noWrap = true;
73
+ // [LAW:one-source-of-truth] For a single-fragment cell (a link cell, or a
74
+ // single-styled non-link fragment), the cell's wrapping style IS that
75
+ // fragment's effective style. This keeps the link / linked-region claim
76
+ // structurally at cell level (where joiners and click dispatch read it)
77
+ // and matches the old per-cell-style contract.
78
+ if (layered.length === 1) {
79
+ cell.style = layered[0]!.style;
80
+ } else if (baseStyle !== undefined && !baseStyle.isNull) {
81
+ cell.style = baseStyle;
82
+ }
83
+ return cell;
84
+ }
85
+
86
+ function withBaseStyle(f: RichText, base: Style): RichText {
87
+ const copy = f.copy();
88
+ copy.style = base.add(f.style);
89
+ return copy;
90
+ }
@@ -0,0 +1,102 @@
1
+ // [LAW:single-enforcer] All per-segment bg/fg resolution flows through
2
+ // resolveSegmentColors. No second path; a second path would silently drift
3
+ // from the two-stage pipeline (bg first, fg with auto-contrast context second).
4
+ //
5
+ // [LAW:dataflow-not-control-flow] Steps execute unconditionally; the option
6
+ // values (undefined template = no spec) are what decides the output, not
7
+ // whether steps run. Absent bg or fg → Style fields are undefined → Style.isNull
8
+ // → fragmentsToStripCells's baseStyle merge is a no-op, cells flow through
9
+ // unchanged.
10
+
11
+ import { Style, ColorSpec } from "@promptctl/rich-js";
12
+ import type { ColorRgba, PaletteResolver } from "@promptctl/rich-js";
13
+ import type { RichText } from "@promptctl/rich-js";
14
+ import type { Template } from "@promptctl/go-template-js";
15
+
16
+ export class ColorSpecError extends Error {
17
+ constructor(spec: string, role: "bg" | "fg") {
18
+ super(`Invalid ${role} color spec: ${JSON.stringify(spec)}`);
19
+ this.name = "ColorSpecError";
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Resolve per-segment bg and fg template strings into a Style for baseStyle
25
+ * injection in fragmentsToStripCells().
26
+ *
27
+ * Pipeline:
28
+ * 1. Evaluate bgTemplate → plain text color-spec string.
29
+ * 2. resolver.resolve(bgSpec) → ColorRgba.
30
+ * 3. Evaluate fgTemplate → plain text color-spec string.
31
+ * 4. resolver.resolve(fgSpec, { against: bgColor }) → ColorRgba (auto-contrast).
32
+ * 5. Wrap as Style({ bgcolor, color }).
33
+ *
34
+ * Undefined template → that color is not set in the returned Style, so cells
35
+ * fall through to their own style (or no color if they have none).
36
+ *
37
+ * Throws ColorSpecError if a non-empty spec string resolves to null.
38
+ *
39
+ * Hue rotation is not this function's concern: per-segment hue lives upstream as
40
+ * WHICH palette `resolver` carries (a transposed palette), so bg and fg resolve
41
+ * from the same transposed palette and their theme-designed relationship is
42
+ * preserved. [LAW:dataflow-not-control-flow]
43
+ *
44
+ * [LAW:dataflow-not-control-flow] Steps are ordered data transformations, not
45
+ * guarded branches. The "no spec" case is represented as undefined, which flows
46
+ * through to produce an absent Style field — not a skipped step.
47
+ */
48
+ export function resolveSegmentColors(
49
+ resolver: PaletteResolver,
50
+ bgTemplate: Template<RichText> | undefined,
51
+ fgTemplate: Template<RichText> | undefined,
52
+ scope: object,
53
+ ): Style {
54
+ const bgSpec = evalToPlainText(bgTemplate, scope);
55
+ const bgColor =
56
+ bgSpec !== undefined
57
+ ? resolveSpec(resolver, bgSpec, undefined, "bg")
58
+ : undefined;
59
+
60
+ const fgSpec = evalToPlainText(fgTemplate, scope);
61
+ const fgColor =
62
+ fgSpec !== undefined
63
+ ? resolveSpec(resolver, fgSpec, bgColor, "fg")
64
+ : undefined;
65
+
66
+ return new Style({
67
+ bgcolor: bgColor !== undefined ? ColorSpec.fromRgba(bgColor) : undefined,
68
+ color: fgColor !== undefined ? ColorSpec.fromRgba(fgColor) : undefined,
69
+ });
70
+ }
71
+
72
+ // Evaluate a template against scope and flatten all fragments to plain text.
73
+ // Returns undefined when no template is configured (no bg/fg override).
74
+ function evalToPlainText(
75
+ template: Template<RichText> | undefined,
76
+ scope: object,
77
+ ): string | undefined {
78
+ if (template === undefined) return undefined;
79
+ return template
80
+ .evaluate(scope)
81
+ .map((f) => f.plain)
82
+ .join("");
83
+ }
84
+
85
+ // Resolve a color spec string through the palette resolver.
86
+ // Throws ColorSpecError (loud failure) if the spec is unknown or the required
87
+ // `against` context is missing — never silently falls back to a default.
88
+ // [LAW:no-defensive-null-guards] null from resolver.resolve signals broken
89
+ // config; the fix is the config, not a silent fallback.
90
+ function resolveSpec(
91
+ resolver: PaletteResolver,
92
+ spec: string,
93
+ against: ColorRgba | undefined,
94
+ role: "bg" | "fg",
95
+ ): ColorRgba {
96
+ const color = resolver.resolve(
97
+ spec.trim(),
98
+ against !== undefined ? { against } : undefined,
99
+ );
100
+ if (color === null) throw new ColorSpecError(spec, role);
101
+ return color;
102
+ }
@@ -0,0 +1,108 @@
1
+ // [LAW:one-source-of-truth] One Engine instance covers all segment template
2
+ // evaluation. Callers parse their template once (engine.parse(src)) and call
3
+ // template.evaluate(scope) per render — the expensive parse step is not
4
+ // repeated per render cycle.
5
+ //
6
+ // Function registry (closed set for cc-candybar):
7
+ // • Go builtins (always registered by the engine): printf, print, println,
8
+ // eq/ne/lt/gt/le/ge, and/or/not, len, index, slice, call.
9
+ // • sprigDefaults: default, empty, coalesce, ternary, fromJson, toJson.
10
+ // • sprigStrings: trunc, lower, upper, replace, trim/trimPrefix/trimSuffix,
11
+ // split/join, contains, hasPrefix, hasSuffix, and more string utils.
12
+ // • sprigLists: has (membership test: `has "v" $list`).
13
+ // • sprigMath: add, sub, mul, div, mod, floor, ceil, round, min, max,
14
+ // seq, until (round here is shadowed by formatterFuncs' Math.round below).
15
+ // • sprigDatetime(clock): now, date, ago, unixEpoch, dateInZone, dateModify,
16
+ // toDate, duration — all reading "now" through the injected clock seam.
17
+ // • sprigConversions: atoi, int, int64, float64, toString, toStrings
18
+ // (int here is shadowed by ccCandybarFuncs' var-system cast below).
19
+ // • sprigDicts: dict, get, set, keys, values, pick, omit, hasKey, merge —
20
+ // `dict` lets a helper take multiple named inputs through its one dot arg.
21
+ // • richTextFuncs: bold, italic, red, green, … (styling from rich-js).
22
+ // • paletteFuncs (when resolver provided): primary, accent, palette, paletteOver, auto.
23
+ // • ccCandybarFuncs: basename, dirname, int, string, bool, urlEncode.
24
+ // • formatterFuncs: minutesUntilReset (clock-reading numeric primitive),
25
+ // formatInteger, round, formatModelName, shortenModelName. (The cost/token/
26
+ // budget AND duration/time-remaining formatters moved to DSL helper templates
27
+ // — see DEFAULT_DSL_CONFIG.helpers.)
28
+
29
+ import {
30
+ createEngine,
31
+ type Engine,
32
+ type FuncMap,
33
+ sprigDefaults,
34
+ sprigStrings,
35
+ sprigLists,
36
+ sprigMath,
37
+ sprigDatetime,
38
+ sprigConversions,
39
+ sprigDicts,
40
+ } from "@promptctl/go-template-js";
41
+ import type { PaletteResolver } from "@promptctl/rich-js";
42
+ import { richTextFuncs, RichText } from "@promptctl/rich-js";
43
+ import { paletteFuncs } from "@promptctl/rich-js/template-bindings";
44
+ import { ccCandybarFuncs, formatterFuncs } from "./funcs.js";
45
+
46
+ // [LAW:single-enforcer] fromString/toString are declared once here.
47
+ // richTextFuncs() provides style functions (bold, red, link, …).
48
+ // paletteFuncs(resolver) registers semantic palette functions when a theme
49
+ // resolver is provided — same engine instance, no second parse path.
50
+ // [LAW:one-way-deps] `extraFuncs` is an INJECTED FuncMap (e.g. the action +
51
+ // picker feature funcs, built in render/action.ts + render/picker.ts). The
52
+ // generic engine never imports a specific feature — the caller hands it the
53
+ // capability as data, so the dependency runs caller → engine, never engine → feature.
54
+ // [LAW:one-type-per-behavior] resolver?/extraFuncs? are values, not modes —
55
+ // one factory, one engine shape; the data (their presence) governs what's
56
+ // registered.
57
+ // [LAW:single-enforcer] `clock` is the one time source. It feeds sprigDatetime
58
+ // (the funcs that read "now") AND createEngine's clock option, so every
59
+ // time-dependent evaluation in this engine reads from one seam. Defaulted here
60
+ // so the default literal `() => new Date()` lives in exactly one place; callers
61
+ // that omit it (and forwarders passing `undefined`) inherit it unchanged.
62
+ export function createCcCandybarEngine(
63
+ resolver?: PaletteResolver,
64
+ extraFuncs?: FuncMap,
65
+ clock: () => Date = () => new Date(),
66
+ ): Engine<RichText> {
67
+ return createEngine<RichText>({
68
+ fromString: (s) => new RichText(s),
69
+ toString: (rt) => rt.plain,
70
+ clock,
71
+ // [LAW:no-defensive-null-guards] missing fields must throw at the boundary,
72
+ // not silently produce "<no value>". Callers (SourceRegistry, segments)
73
+ // depend on MissingFieldError to drive varDefault / defaultEmptyValue.
74
+ missingKey: "error",
75
+ funcs: {
76
+ ...sprigDefaults(),
77
+ ...sprigStrings(),
78
+ ...sprigLists(),
79
+ ...sprigMath(),
80
+ // [LAW:single-enforcer] one clock seam: the same source createEngine holds.
81
+ ...sprigDatetime(clock),
82
+ ...sprigConversions(),
83
+ // [LAW:types-are-the-program] `dict` is the substrate primitive a helper
84
+ // uses to receive more than one input through its single dot arg:
85
+ // `{{ template "budgetStatus" (dict "cost" .x "budget" .y "warn" .z) }}`.
86
+ // It makes a multi-input formatter's domain exactly {named scalars},
87
+ // decoupled from any payload's nesting — no per-payload helper variant.
88
+ ...sprigDicts(),
89
+ ...richTextFuncs(),
90
+ ...(resolver !== undefined ? paletteFuncs(resolver) : {}),
91
+ // Domain-specific overrides last (wins on collision with sprig aliases).
92
+ // [LAW:one-source-of-truth] ccCandybarFuncs' `int` is the var-system cast
93
+ // (toNumber over VarValue); it intentionally shadows sprigConversions' `int`
94
+ // so a template's `int` keeps one meaning. Position is the override policy.
95
+ ...ccCandybarFuncs(),
96
+ // [LAW:one-source-of-truth] formatter funcs delegate to
97
+ // src/utils/formatters.ts; minutesUntilReset reads the same `clock` seam.
98
+ // The only sprig collision is `round`: formatterFuncs' Math.round shadows
99
+ // sprigMath's precision-aware round, registered last so the domain meaning
100
+ // wins (revisited by the bdi cleanup ticket).
101
+ ...formatterFuncs(clock),
102
+ // [LAW:locality-or-seam] Injected feature funcs (the daemon's per-config
103
+ // engine supplies the `action` + `picker` funcs; resolver-less compile-only
104
+ // paths do not). Last so a feature can override on collision.
105
+ ...(extraFuncs ?? {}),
106
+ },
107
+ });
108
+ }
@@ -0,0 +1,216 @@
1
+ // [LAW:one-source-of-truth] Cast semantics live in var-system/types.ts.
2
+ // This module wraps them as FuncMap entries — it does not duplicate logic.
3
+ // [LAW:single-enforcer] The boundary gate (argTypes) lives at the engine
4
+ // dispatch site; the bodies here trust the runtime types they declared.
5
+
6
+ import { basename as pathBasename, dirname as pathDirname } from "path";
7
+ import type { FuncMap } from "@promptctl/go-template-js";
8
+ import {
9
+ toNumber,
10
+ toString,
11
+ toBool,
12
+ type VarValue,
13
+ } from "../var-system/types.js";
14
+ import {
15
+ formatInteger,
16
+ formatModelName,
17
+ shortenModelName,
18
+ } from "../utils/formatters.js";
19
+ import { listResolvablePaletteNames, STYLE_ORDER } from "../themes/policy.js";
20
+
21
+ // [LAW:one-source-of-truth] The DSL `themes()` and `styles()` bindings
22
+ // project the SAME canonical sources the set-state validator consults
23
+ // (listResolvablePaletteNames / STYLE_ORDER). A picker (or a config that
24
+ // `range`s over themes() to emit OSC-8 cells) iterates the allow-list the
25
+ // validator will enforce on the resulting click — the list and the gate cannot
26
+ // diverge because there is no second list.
27
+ //
28
+ // Module-init caching is correct by construction: rich-js THEMES is a
29
+ // static import (no dynamic palette registration at runtime) and
30
+ // STYLE_ORDER is a const array. The "reactivity" requirement from the
31
+ // ticket is satisfied vacuously — the lists never change during a
32
+ // daemon lifetime, so a cached snapshot IS the current truth.
33
+ const THEMES_LIST: readonly string[] = listResolvablePaletteNames();
34
+ const STYLES_LIST: readonly string[] = [...STYLE_ORDER];
35
+
36
+ // Normalize an engine-supplied numeric argument. The "number" argType admits
37
+ // both number and bigint (per @promptctl/go-template-js); the underlying
38
+ // formatters take a JS number, so collapse bigint here. [LAW:single-enforcer]
39
+ // every formatter wrapper goes through this — no per-wrapper bigint check.
40
+ //
41
+ // [LAW:no-silent-fallbacks] A bigint outside JS's safe-integer range cannot
42
+ // round-trip through Number without silent precision loss (53-bit mantissa)
43
+ // or overflow to ±Infinity. Either would feed a wrong value into a formatter
44
+ // (e.g. formatDuration) and produce confidently-wrong output. Throw at the
45
+ // conversion boundary so the failure surfaces where the conversion happens,
46
+ // not deep inside a formatter doing math on a corrupted number.
47
+ function num(v: number | bigint): number {
48
+ if (typeof v === "bigint") {
49
+ if (
50
+ v > BigInt(Number.MAX_SAFE_INTEGER) ||
51
+ v < BigInt(Number.MIN_SAFE_INTEGER)
52
+ ) {
53
+ throw new TypeError(
54
+ `Numeric argument ${v}n is outside JS safe-integer range ` +
55
+ `(|v| > Number.MAX_SAFE_INTEGER = ${Number.MAX_SAFE_INTEGER}); ` +
56
+ `Number(v) would lose precision or overflow. ` +
57
+ `Pass a value within ±Number.MAX_SAFE_INTEGER.`,
58
+ );
59
+ }
60
+ return Number(v);
61
+ }
62
+ return v;
63
+ }
64
+
65
+ // cc-candybar-specific functions not already covered by sprig or Go builtins.
66
+ // The engine also includes sprigDefaults(), sprigStrings(), and sprigLists()
67
+ // which cover: default, trunc, lower, upper, replace, trim/trimPrefix/trimSuffix,
68
+ // split/join, contains/hasPrefix/hasSuffix, has.
69
+ // Go builtins cover: printf, eq/ne/lt/gt/le/ge, and/or/not.
70
+ export function ccCandybarFuncs(): FuncMap {
71
+ return {
72
+ // Path operations absent from sprig in this package.
73
+ basename: {
74
+ fn: (s: string) => pathBasename(s),
75
+ argTypes: ["string"],
76
+ },
77
+ dirname: {
78
+ fn: (s: string) => pathDirname(s),
79
+ argTypes: ["string"],
80
+ },
81
+
82
+ // [LAW:single-enforcer] Type casts delegate to var-system/types.ts.
83
+ // "value" argType: these funcs enforce their own constraints and emit
84
+ // a useful TypeError on ambiguous input — no need for the engine gate
85
+ // to pre-filter (it can't describe the partial-cast semantics anyway).
86
+ int: {
87
+ fn: (v: VarValue) => toNumber(v),
88
+ argTypes: ["value"],
89
+ },
90
+ string: {
91
+ fn: (v: VarValue) => toString(v),
92
+ argTypes: ["value"],
93
+ },
94
+ bool: {
95
+ fn: (v: VarValue) => toBool(v),
96
+ argTypes: ["value"],
97
+ },
98
+
99
+ // [LAW:single-enforcer] One URL-encoding function for click-verb URL
100
+ // construction in templates. encodeURIComponent matches the legacy
101
+ // src/segments/renderer.ts toolbar/tray renderers, so DSL-emitted
102
+ // cc-candybar://verb/<value> URLs are byte-identical to legacy ones.
103
+ // [LAW:types-are-the-program] Domain primitive surfaced by chunk-7/8
104
+ // migration (vhi.3) — the proposal explicitly budgets "may add one or
105
+ // two filters during migration." This is one of those.
106
+ urlEncode: {
107
+ fn: (s: string) => encodeURIComponent(s),
108
+ argTypes: ["string"],
109
+ },
110
+
111
+ // [LAW:one-source-of-truth] themes() and styles() are zero-arg
112
+ // projections of the daemon's canonical domain lists. A picker (or a
113
+ // hand-authored `range`) expresses "options come from list Y" by
114
+ // iterating these bindings; the same lists feed the set-state
115
+ // validator's allow-list checks, so the rendered options are exactly
116
+ // the values the next click will be allowed to write. No second
117
+ // enumeration in user config.
118
+ // [LAW:dataflow-not-control-flow] A range loop over a list IS the
119
+ // option primitive — `{{ range themes }}…{{ end }}` produces one
120
+ // rendered cell per allowed value. Adding a theme adds a cell;
121
+ // removing a theme removes a cell; no template branch on "how many
122
+ // themes are there."
123
+ themes: {
124
+ fn: () => THEMES_LIST,
125
+ argTypes: [],
126
+ },
127
+ styles: {
128
+ fn: () => STYLES_LIST,
129
+ argTypes: [],
130
+ },
131
+ };
132
+ }
133
+
134
+ // [LAW:one-source-of-truth] Domain value formatters. What remains after the
135
+ // formatting-as-data epic (bdi) are primitives with NO template-native
136
+ // expression — the bdi migration is complete. Do NOT migrate these to DSL
137
+ // helpers; each one is retained for a load-bearing reason:
138
+ //
139
+ // minutesUntilReset — returns a NUMBER for comparisons and arithmetic
140
+ // (`le (minutesUntilReset .x) 8`). A template helper writes to output
141
+ // and cannot return a value, so a helper form would duplicate the formula
142
+ // across every comparison site [LAW:one-source-of-truth].
143
+ //
144
+ // formatInteger — locale-aware grouping via toLocaleString(). A regex
145
+ // helper would be locale-blind (always comma+3), a second divergent
146
+ // producer [LAW:one-source-of-truth]. The daemon inherits shell LANG/LC_*
147
+ // from the Rust spawner so grouping honors the user's locale at runtime.
148
+ //
149
+ // round — Math.round (half-away-from-zero) consumed in `{{ round .pct }}%`
150
+ // segments. shadows sprigMath's precision-aware round intentionally:
151
+ // block/weekly/context need integer-rounding, not decimal rounding.
152
+ //
153
+ // formatModelName / shortenModelName — regex parsing of external model IDs
154
+ // (named capture groups, version assembly, variant stripping). Trust-
155
+ // boundary normalization, not display policy. No regex primitive in DSL.
156
+ //
157
+ // The display-formatting families moved to DSL helpers in DEFAULT_DSL_CONFIG:
158
+ // cost/token/budget (bdi.3), duration/time-remaining (bdi.4).
159
+ //
160
+ // [LAW:single-enforcer] minutesUntilReset reads "now" from the injected
161
+ // `clock` — the SAME seam createCcCandybarEngine threads to sprigDatetime
162
+ // (now/unixEpoch) and createEngine. One clock governs every time-dependent
163
+ // evaluation; tests inject a frozen clock for determinism.
164
+ export function formatterFuncs(clock: () => Date = () => new Date()): FuncMap {
165
+ return {
166
+ // Epoch-seconds → whole minutes until that instant, clamped at 0 for a past
167
+ // expiry: round(max(0, epoch*1000 − now)/60000). Consumed by the block/weekly
168
+ // segments (`formatLongTimeRemaining (minutesUntilReset .resetsAt)`) and the
169
+ // cacheTimer warmth countdown (numeric `le` thresholds).
170
+ minutesUntilReset: {
171
+ fn: (epochSeconds: number | bigint) =>
172
+ Math.round(
173
+ Math.max(0, num(epochSeconds) * 1000 - clock().getTime()) / 60000,
174
+ ),
175
+ argTypes: ["number"],
176
+ },
177
+
178
+ // ─── Locale-grouped integer (context's "50,000") ──────────────────
179
+ // [LAW:one-source-of-truth] bdi.5: deliberately RETAINED as a primitive
180
+ // (not migrated to a DSL helper). toLocaleString reads the host locale the
181
+ // daemon inherits, so grouping is locale-correct (en_US "50,000" /
182
+ // de_DE "50.000"). A regex helper would be a second, locale-blind producer
183
+ // of grouping policy — same parsing/formatting boundary that keeps
184
+ // formatModelName here.
185
+ formatInteger: {
186
+ fn: (n: number | bigint) => formatInteger(num(n)),
187
+ argTypes: ["number"],
188
+ },
189
+
190
+ // ─── Numeric helper (block/weekly's Math.round of pct) ────────────
191
+ // [LAW:one-source-of-truth] Math.round is a JS built-in shared between
192
+ // legacy and DSL — no wrapper indirection makes sense for it. The
193
+ // formatters.ts module documents domain-meaningful rules; rounding is
194
+ // not domain-meaningful, so it stays here.
195
+ round: {
196
+ fn: (n: number | bigint) => Math.round(num(n)),
197
+ argTypes: ["number"],
198
+ },
199
+
200
+ // ─── Model-name normalizers (chunk-7 model dsl-pending → dsl-parity) ─
201
+ // [LAW:one-source-of-truth] formatModelName / shortenModelName are regex-
202
+ // based normalizers; the DSL function set has no regex primitive, so the
203
+ // only honest way to express them is to wrap the canonical impls. The
204
+ // model binding can then move from "echo display_name verbatim" (only
205
+ // byte-parity for friendly names) to "echo normalized model name" (full
206
+ // behavioral parity, including raw IDs like "claude-sonnet-4-6").
207
+ formatModelName: {
208
+ fn: (raw: string) => formatModelName(raw),
209
+ argTypes: ["string"],
210
+ },
211
+ shortenModelName: {
212
+ fn: (formatted: string) => shortenModelName(formatted),
213
+ argTypes: ["string"],
214
+ },
215
+ };
216
+ }
@@ -0,0 +1,11 @@
1
+ export { createCcCandybarEngine } from "./engine.js";
2
+ export { buildScope } from "./scope.js";
3
+ export { ccCandybarFuncs } from "./funcs.js";
4
+ export { fragmentsToCells } from "./cells.js";
5
+ export { evaluateWhen, applySegmentLayout } from "./layout.js";
6
+ export type {
7
+ SegmentLayoutOptions,
8
+ JustifyMode,
9
+ TruncateMode,
10
+ } from "./layout.js";
11
+ export { resolveSegmentColors, ColorSpecError } from "./colors.js";
@@ -0,0 +1,112 @@
1
+ // [LAW:single-enforcer] All per-segment width/justify/truncate enforcement
2
+ // runs through applySegmentLayout. RichText owns the slice/pad/truncate
3
+ // primitives; this function chooses which to call from the segment-level
4
+ // options. No second path exists.
5
+ //
6
+ // [LAW:dataflow-not-control-flow] Every step is unconditional in shape;
7
+ // option values (width, justify, truncate) decide what the output is, not
8
+ // whether the step runs. "auto" width is not a branch that skips logic —
9
+ // it is a value that selects "collapse only, no resize" over "collapse then
10
+ // size to width".
11
+ //
12
+ // [LAW:types-are-the-program] With RichText as the cell type, every layout
13
+ // operation is span-preserving by construction. There is no rebuild path,
14
+ // no slice-then-restyle dance: `richText.truncate({width, mode, marker})`
15
+ // and `richText.align(justify, width)` clip and shift spans through every
16
+ // cut. The bzh.9 limitation (truncation drops per-part fg) cannot be
17
+ // expressed in this shape — its preconditions don't exist.
18
+
19
+ import { RichText } from "@promptctl/rich-js";
20
+ import type { Style } from "@promptctl/rich-js";
21
+ import type { Template } from "@promptctl/go-template-js";
22
+
23
+ export type JustifyMode = "left" | "center" | "right";
24
+ export type TruncateMode = "right" | "left" | "middle";
25
+
26
+ export interface SegmentLayoutOptions {
27
+ /** "auto" → content-sized; a positive integer → fixed terminal-cell width. */
28
+ width: "auto" | number;
29
+ /** Alignment within a fixed-width segment. Ignored when width is "auto". */
30
+ justify: JustifyMode;
31
+ /** Overflow strategy when content exceeds a fixed width. Ignored when "auto". */
32
+ truncate: TruncateMode;
33
+ /** Glyph inserted at the overflow cut point. Default "…". */
34
+ truncateMarker?: string;
35
+ /**
36
+ * Style for synthesized whitespace — RichText pads using plain spaces.
37
+ * The padding inherits the cell's wrapping style at render time, so the
38
+ * segment bg/fg is continuous across padded gaps without a second style
39
+ * assignment here.
40
+ */
41
+ baseStyle?: Style;
42
+ }
43
+
44
+ /**
45
+ * Evaluate a `when` predicate template against `scope`.
46
+ * Returns false only when the evaluated text equals the string "false".
47
+ * A missing template means the segment is always visible.
48
+ *
49
+ * [LAW:dataflow-not-control-flow] Visibility is a value that flows out of the
50
+ * template engine. The engine always runs; the output value decides visibility.
51
+ */
52
+ export function evaluateWhen(
53
+ template: Template<RichText> | undefined,
54
+ scope: object,
55
+ ): boolean {
56
+ if (template === undefined) return true;
57
+ const fragments = template.evaluate(scope);
58
+ return fragments.map((f) => f.plain).join("") !== "false";
59
+ }
60
+
61
+ /**
62
+ * Collapse a visual line's cells into the ONE strip item the unit contributes
63
+ * for that line. [LAW:single-enforcer] A unit (segment, or an inline leaf) is
64
+ * one strip item — the powerline joiner caps BETWEEN units, never inside one,
65
+ * so a unit's interior bg/fg variation is paint, not a structural seam the
66
+ * joiner reads. Each input cell's wrapping style becomes a span over its range
67
+ * and its interior spans (including OSC-8 links) carry through, so every
68
+ * clickable region survives as its own span — serialized as one OSC-8 region
69
+ * each. `baseStyle` is the wrapping default so synthesized padding and gaps
70
+ * inherit the unit's bg.
71
+ */
72
+ function collapseToCell(
73
+ cells: readonly RichText[],
74
+ baseStyle?: Style,
75
+ ): RichText {
76
+ const merged = RichText.fromFragments(cells);
77
+ merged.end = "";
78
+ merged.noWrap = true;
79
+ if (baseStyle !== undefined && !baseStyle.isNull) merged.style = baseStyle;
80
+ return merged;
81
+ }
82
+
83
+ /**
84
+ * Lay out one segment visual line: collapse its cells into a single strip
85
+ * item, then size that item to the requested width. Returns `[]` for an empty
86
+ * line (a unit that rendered nothing contributes no strip item) or `[cell]`
87
+ * for one — never more, so the caller's branchless spread handles both.
88
+ *
89
+ * [LAW:dataflow-not-control-flow] `width` is the value that selects the sizing
90
+ * op: "auto" keeps the content-sized cell as-is; a fixed width truncates when
91
+ * over and pad-aligns when under. Truncation/align are span-preserving, so the
92
+ * collapsed link structure survives every cut.
93
+ */
94
+ export function applySegmentLayout(
95
+ cells: readonly RichText[],
96
+ options: SegmentLayoutOptions,
97
+ ): RichText[] {
98
+ const { width, justify, truncate, truncateMarker = "…", baseStyle } = options;
99
+
100
+ if (cells.length === 0) return [];
101
+
102
+ const cell = collapseToCell(cells, baseStyle);
103
+ if (width === "auto") return [cell];
104
+
105
+ if (cell.cellLength > width) {
106
+ cell.truncate(width, { mode: truncate, marker: truncateMarker });
107
+ } else if (cell.cellLength < width) {
108
+ cell.align(justify, width);
109
+ }
110
+
111
+ return [cell];
112
+ }