@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,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
|
+
}
|