@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,611 @@
|
|
|
1
|
+
// [LAW:single-enforcer] The single place where the daemon assembles every
|
|
2
|
+
// data field the DSL templates can read. The output of this function — the
|
|
3
|
+
// `RenderPayload` — is fed verbatim to `registry.applyInput(...)` (inside
|
|
4
|
+
// renderDsl), and every `kind: "input"` variable in the default DSL
|
|
5
|
+
// config resolves its `path` against exactly this shape.
|
|
6
|
+
//
|
|
7
|
+
// [LAW:dataflow-not-control-flow] Variability lives in the values flowing
|
|
8
|
+
// through here, not in hand-coded branches. The set of providers actually
|
|
9
|
+
// invoked is selected by inspecting the DslConfig's declared input paths —
|
|
10
|
+
// the *config* (data) chooses what runs. Templates use `when` predicates
|
|
11
|
+
// and inline guards on the resulting values to decide what renders.
|
|
12
|
+
//
|
|
13
|
+
// [LAW:one-source-of-truth] `RenderPayload` is the contract between the
|
|
14
|
+
// daemon's data-provider fleet and the DSL config's input declarations. The
|
|
15
|
+
// default config in `src/config/default-dsl-config.ts` declares input paths
|
|
16
|
+
// that mirror this shape; user configs MUST agree (a path that doesn't
|
|
17
|
+
// resolve falls back to the variable's declared default).
|
|
18
|
+
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import type { ClaudeHookData } from "../utils/claude.js";
|
|
21
|
+
import type { DslConfig, VariableDecl } from "../config/dsl-types.js";
|
|
22
|
+
import { walkNodes } from "../config/dsl-types.js";
|
|
23
|
+
import { extractTemplateRefs } from "../config/dsl-loader.js";
|
|
24
|
+
import type { GitInfo, GitInfoOptions } from "../segments/git.js";
|
|
25
|
+
import { ABSENT, failed, type Outcome } from "../utils/outcome.js";
|
|
26
|
+
import { cacheExpiresAt } from "../segments/cache.js";
|
|
27
|
+
import type { DaemonLogger } from "./log.js";
|
|
28
|
+
import type { SessionUsageStore } from "./cache/session-usage-store.js";
|
|
29
|
+
import type { ContextProvider } from "../segments/context.js";
|
|
30
|
+
import type { MetricsProvider } from "../segments/metrics.js";
|
|
31
|
+
import type { TmuxService } from "../segments/tmux.js";
|
|
32
|
+
import type { GitDataProvider } from "./cache/git.js";
|
|
33
|
+
import type { SessionStateRW } from "./session-state.js";
|
|
34
|
+
|
|
35
|
+
// ─── Augmented payload shape ─────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
// [LAW:types-are-the-program] The RenderPayload extends ClaudeHookData with
|
|
38
|
+
// daemon-computed fields. The new keys are all OPTIONAL on the type because
|
|
39
|
+
// individual provider failures (no transcript, no git repo, no tmux) leave
|
|
40
|
+
// their slots null/missing — and the DSL templates handle absence via
|
|
41
|
+
// `when`/inline guards, never by branching in this code.
|
|
42
|
+
export interface RenderPayload extends ClaudeHookData {
|
|
43
|
+
// env-style values surfaced as paths so the DSL can read them via `input`
|
|
44
|
+
// alongside the rest of the payload. (`kind: "env"` is also available for
|
|
45
|
+
// arbitrary env-var lookups in user configs.)
|
|
46
|
+
readonly home?: string;
|
|
47
|
+
|
|
48
|
+
// ─── Daemon-computed augmentations ───────────────────────────────────────
|
|
49
|
+
// The provider's null/absent return becomes a missing field; the DSL's
|
|
50
|
+
// input-var fallback chain fills in the default.
|
|
51
|
+
|
|
52
|
+
readonly git?: GitPayload;
|
|
53
|
+
readonly tmux?: { readonly session: string };
|
|
54
|
+
readonly theme?: string;
|
|
55
|
+
|
|
56
|
+
// Usage-family. Each provider returns null when it has no data (no
|
|
57
|
+
// transcript yet, no rate-limit window active, etc.); we drop the field
|
|
58
|
+
// rather than emit zeros, so an unconfigured user sees their declared
|
|
59
|
+
// `default`. Individual fields inside each sub-object are ALSO optional —
|
|
60
|
+
// "we have a metrics object but messageCount couldn't be computed" is
|
|
61
|
+
// representable distinct from "metrics object zeroed because we had to
|
|
62
|
+
// satisfy a non-optional type." Absence flows through `applyInput`'s
|
|
63
|
+
// fallback chain (which writes both the default value AND a last_error)
|
|
64
|
+
// exactly like a missing top-level field.
|
|
65
|
+
readonly session?: SessionPayload;
|
|
66
|
+
readonly today?: TodayPayload;
|
|
67
|
+
readonly block?: BlockPayload;
|
|
68
|
+
readonly weekly?: WeeklyPayload;
|
|
69
|
+
readonly cache?: CachePayload;
|
|
70
|
+
readonly context?: ContextPayload;
|
|
71
|
+
readonly metrics?: MetricsPayload;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Flattened projection of GitInfo: every field shape the parity bindings
|
|
75
|
+
// reference. [LAW:one-type-per-behavior] Same absence policy as
|
|
76
|
+
// MetricsPayload: every field can independently be absent (not requested,
|
|
77
|
+
// genuinely none, or its fetch failed), and absence is PRESERVED — the DSL
|
|
78
|
+
// input fallback chain emits the declared `default` and records a
|
|
79
|
+
// `last_error` per field. The old coerce-to-''/0 shim erased the distinction
|
|
80
|
+
// between "no stashes" and "stash count unknown because git failed".
|
|
81
|
+
export interface GitPayload {
|
|
82
|
+
readonly repoName?: string;
|
|
83
|
+
readonly branch?: string;
|
|
84
|
+
readonly sha?: string;
|
|
85
|
+
readonly ahead?: number;
|
|
86
|
+
readonly behind?: number;
|
|
87
|
+
readonly staged?: number;
|
|
88
|
+
readonly unstaged?: number;
|
|
89
|
+
readonly untracked?: number;
|
|
90
|
+
readonly conflicts?: number;
|
|
91
|
+
readonly upstream?: string;
|
|
92
|
+
readonly stash?: number;
|
|
93
|
+
readonly status?: string;
|
|
94
|
+
readonly operation?: string;
|
|
95
|
+
readonly timeSinceCommit?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SessionPayload {
|
|
99
|
+
readonly cost?: number;
|
|
100
|
+
readonly tokens?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface TodayPayload {
|
|
104
|
+
readonly cost?: number;
|
|
105
|
+
readonly tokens?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface BlockPayload {
|
|
109
|
+
readonly nativeUtilization: number;
|
|
110
|
+
readonly resetsAt: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface WeeklyPayload {
|
|
114
|
+
readonly percentage: number;
|
|
115
|
+
readonly resetsAt: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Prompt-cache warmth. One field — the epoch-seconds expiry instant —
|
|
119
|
+
// mirroring block/weekly `resetsAt` so the DSL composes the countdown via
|
|
120
|
+
// `minutesUntilReset`. Absent when no cache-bearing transcript entry exists.
|
|
121
|
+
export interface CachePayload {
|
|
122
|
+
readonly expiresAt: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ContextPayload {
|
|
126
|
+
readonly totalTokens: number;
|
|
127
|
+
readonly contextLeft: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// [LAW:types-are-the-program] Each metrics field can independently be absent
|
|
131
|
+
// (transcript missing, cost block absent, response-time math undefined). The
|
|
132
|
+
// optional fields make "no data for this dimension" distinguishable from
|
|
133
|
+
// "real zero" — the DSL input fallback chain emits the declared `default`
|
|
134
|
+
// for absent fields and records a `last_error` (`debug vars` surfaces it).
|
|
135
|
+
// A coerce-to-zero shim here would erase that distinction.
|
|
136
|
+
export interface MetricsPayload {
|
|
137
|
+
readonly lastResponseTime?: number;
|
|
138
|
+
readonly responseTime?: number;
|
|
139
|
+
readonly sessionDuration?: number;
|
|
140
|
+
readonly messageCount?: number;
|
|
141
|
+
readonly linesAdded?: number;
|
|
142
|
+
readonly linesRemoved?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Provider dependencies ────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
export interface RenderPayloadDeps {
|
|
148
|
+
readonly gitProvider: GitDataProvider;
|
|
149
|
+
// [LAW:one-source-of-truth] One store backs BOTH the `session` and `today`
|
|
150
|
+
// projections — they are folds over the same per-session records, not two
|
|
151
|
+
// independent providers that could disagree.
|
|
152
|
+
readonly usageStore: SessionUsageStore;
|
|
153
|
+
readonly contextProvider: ContextProvider;
|
|
154
|
+
readonly metricsProvider: MetricsProvider;
|
|
155
|
+
readonly tmuxService: TmuxService;
|
|
156
|
+
readonly sessionState: SessionStateRW;
|
|
157
|
+
// [LAW:single-enforcer] The log capability for every provider lane:
|
|
158
|
+
// buildRenderPayload is the ONE place lane failures are logged, so the
|
|
159
|
+
// providers' interiors never log and never double-log.
|
|
160
|
+
readonly log: DaemonLogger;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Builder ─────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
// ─── Config-driven provider gating ───────────────────────────────────────────
|
|
166
|
+
//
|
|
167
|
+
// [LAW:dataflow-not-control-flow] Whether a provider fires is selected by
|
|
168
|
+
// the active layout. Walk from `config.root` → cells nodes → their segments →
|
|
169
|
+
// their template strings → referenced variable names → recursive expansion through
|
|
170
|
+
// `template`-kind vars. The transitive closure tells us which input paths
|
|
171
|
+
// are actually reachable from a rendered segment; providers feeding paths
|
|
172
|
+
// outside that closure do not run.
|
|
173
|
+
//
|
|
174
|
+
// [LAW:single-enforcer] One reachability walk owns "is this provider
|
|
175
|
+
// needed." A declared-but-unreachable input variable (the default config
|
|
176
|
+
// declares every built-in variable for reference completeness) contributes
|
|
177
|
+
// no work to the hot path. Exported so the cache can compute the closure
|
|
178
|
+
// once at registration time (config is stable per cache entry) and reuse
|
|
179
|
+
// it across renders.
|
|
180
|
+
export function buildNeededPrefixes(config: DslConfig): ReadonlySet<string> {
|
|
181
|
+
// 1. Variable name → declaration index for fast lookup. Global vars first;
|
|
182
|
+
// per-segment vars are namespaced `segName.varName` (same as runtime).
|
|
183
|
+
const allDecls = new Map<string, VariableDecl>();
|
|
184
|
+
for (const [name, decl] of Object.entries(config.variables)) {
|
|
185
|
+
allDecls.set(name, decl);
|
|
186
|
+
}
|
|
187
|
+
for (const [segName, seg] of Object.entries(config.segments)) {
|
|
188
|
+
if (!seg.vars) continue;
|
|
189
|
+
for (const [varName, decl] of Object.entries(seg.vars)) {
|
|
190
|
+
allDecls.set(`${segName}.${varName}`, decl);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. BFS from layout segments. Frontier seeds with refs from each
|
|
195
|
+
// rendered segment's template/when/bg/fg ONLY — segment-local vars in
|
|
196
|
+
// `seg.vars` are reached transitively via those refs (their declared
|
|
197
|
+
// names appear in the templates that need them). Seeding from
|
|
198
|
+
// `seg.vars` directly would mark unused per-segment template vars as
|
|
199
|
+
// needed and pull in their providers without justification.
|
|
200
|
+
// `visited` tracks vars whose own `template`-kind body we've already
|
|
201
|
+
// followed.
|
|
202
|
+
const frontier: string[] = [];
|
|
203
|
+
const visited = new Set<string>();
|
|
204
|
+
|
|
205
|
+
for (const node of walkNodes(config.root)) {
|
|
206
|
+
// A node's `when` references variables too — seed them so a provider feeding
|
|
207
|
+
// only a predicate (e.g. a state var gating a row/container) isn't gated out.
|
|
208
|
+
if (node.when)
|
|
209
|
+
for (const ref of extractTemplateRefs(node.when)) frontier.push(ref);
|
|
210
|
+
if (node.kind !== "segment") continue;
|
|
211
|
+
const seg = config.segments[node.name];
|
|
212
|
+
if (!seg) continue;
|
|
213
|
+
for (const src of [seg.template, seg.when, seg.bg, seg.fg]) {
|
|
214
|
+
if (src) for (const ref of extractTemplateRefs(src)) frontier.push(ref);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 3. Walk the closure. A ref is the dotted form `a.b.c`. Two cases:
|
|
219
|
+
// - LEAF: `ref` exactly matches a declared variable. The scope proxy
|
|
220
|
+
// treats this as the variable read.
|
|
221
|
+
// - NAMESPACE: `ref` is a strict prefix of declared variable names
|
|
222
|
+
// (e.g. `git` when only `git.branch`, `git.sha`, … are declared).
|
|
223
|
+
// The scope proxy returns a nested proxy here, which the template
|
|
224
|
+
// can iterate / stringify / pass to functions like `toJson`. A
|
|
225
|
+
// namespace read implicitly reaches every leaf under it, so every
|
|
226
|
+
// `<ref>.*` declaration becomes reachable.
|
|
227
|
+
// Both cases collapse to "expand the ref to every matching declared
|
|
228
|
+
// name." For template-kind matches, the body refs become new frontier
|
|
229
|
+
// items; for input-kind matches, the path is added to the closure.
|
|
230
|
+
const inputPaths = new Set<string>();
|
|
231
|
+
while (frontier.length > 0) {
|
|
232
|
+
const ref = frontier.pop()!;
|
|
233
|
+
for (const declName of expandRef(allDecls, ref)) {
|
|
234
|
+
if (visited.has(declName)) continue;
|
|
235
|
+
visited.add(declName);
|
|
236
|
+
const decl = allDecls.get(declName);
|
|
237
|
+
if (!decl) continue;
|
|
238
|
+
if (decl.kind === "input") {
|
|
239
|
+
inputPaths.add(decl.path);
|
|
240
|
+
} else if (decl.kind === "template") {
|
|
241
|
+
for (const r of extractTemplateRefs(decl.template)) {
|
|
242
|
+
frontier.push(r);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Other kinds (literal/env/file/shell/time/git/state) declare their
|
|
246
|
+
// own box without reading a payload path — they need no provider
|
|
247
|
+
// gating because the daemon's payload builder is the only thing
|
|
248
|
+
// this gates.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return inputPaths;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// [LAW:single-enforcer] Mirror of the scope proxy's read semantics: a ref
|
|
256
|
+
// resolves either to an exact variable (leaf) or to every variable under a
|
|
257
|
+
// namespace prefix (`.git` matches `git.branch`, `git.sha`, …). Yields the
|
|
258
|
+
// set of declared names the ref reaches.
|
|
259
|
+
function expandRef(
|
|
260
|
+
decls: ReadonlyMap<string, VariableDecl>,
|
|
261
|
+
ref: string,
|
|
262
|
+
): readonly string[] {
|
|
263
|
+
// Leaf — most specific declared name wins, mirroring lookupDecl's loop.
|
|
264
|
+
let candidate = ref;
|
|
265
|
+
while (candidate.length > 0) {
|
|
266
|
+
if (decls.has(candidate)) return [candidate];
|
|
267
|
+
const dot = candidate.lastIndexOf(".");
|
|
268
|
+
if (dot < 0) break;
|
|
269
|
+
candidate = candidate.slice(0, dot);
|
|
270
|
+
}
|
|
271
|
+
// Namespace — every name starting with `${ref}.` is reachable.
|
|
272
|
+
const ns = `${ref}.`;
|
|
273
|
+
const matches: string[] = [];
|
|
274
|
+
for (const name of decls.keys()) {
|
|
275
|
+
if (name.startsWith(ns)) matches.push(name);
|
|
276
|
+
}
|
|
277
|
+
return matches;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function anyPathStartsWith(
|
|
281
|
+
paths: ReadonlySet<string>,
|
|
282
|
+
prefix: string,
|
|
283
|
+
): boolean {
|
|
284
|
+
for (const p of paths) {
|
|
285
|
+
if (p === prefix || p.startsWith(prefix + ".")) return true;
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// [LAW:dataflow-not-control-flow] Each `show*` flag is derived from a
|
|
291
|
+
// specific declared input path; the closure tells us exactly which fields
|
|
292
|
+
// the user's templates will read. Without this, GitService.getGitInfo
|
|
293
|
+
// silently returns "" / 0 for fields whose `show*` flag isn't set (because
|
|
294
|
+
// computing them requires extra git invocations), and a user who declares
|
|
295
|
+
// `git.sha` or `git.staged` would see their template evaluate against
|
|
296
|
+
// empty strings or zeros.
|
|
297
|
+
function gitOptionsFromClosure(needed: ReadonlySet<string>): GitInfoOptions {
|
|
298
|
+
const has = (path: string): boolean => needed.has(path);
|
|
299
|
+
// `git.staged` / `git.unstaged` / `git.untracked` / `git.conflicts` all
|
|
300
|
+
// come from one `git status --porcelain` call — any one of them turning
|
|
301
|
+
// the flag on enables all four.
|
|
302
|
+
const wantsWorkingTree =
|
|
303
|
+
has("git.staged") ||
|
|
304
|
+
has("git.unstaged") ||
|
|
305
|
+
has("git.untracked") ||
|
|
306
|
+
has("git.conflicts");
|
|
307
|
+
return {
|
|
308
|
+
...(has("git.sha") && { showSha: true }),
|
|
309
|
+
...(wantsWorkingTree && { showWorkingTree: true }),
|
|
310
|
+
...(has("git.stash") && { showStashCount: true }),
|
|
311
|
+
...(has("git.upstream") && { showUpstream: true }),
|
|
312
|
+
...(has("git.repoName") && { showRepoName: true }),
|
|
313
|
+
...(has("git.operation") && { showOperation: true }),
|
|
314
|
+
...(has("git.timeSinceCommit") && { showTimeSinceCommit: true }),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Compose every render-time data source into the augmented payload that the
|
|
320
|
+
* DSL applies to its input variables.
|
|
321
|
+
*
|
|
322
|
+
* Each provider runs only if its payload prefix sits in the closure
|
|
323
|
+
* computed by `buildNeededPrefixes(config)` — the set of `kind: "input"`
|
|
324
|
+
* paths transitively reachable from a segment in `config.root`. Merely
|
|
325
|
+
* declaring an input variable does NOT trigger provider work; the variable
|
|
326
|
+
* must actually be referenced by a layout-rendered segment (directly, or
|
|
327
|
+
* via a chain of `template`-kind vars). The default config declares many
|
|
328
|
+
* unused inputs for reference completeness — switching one on is a layout
|
|
329
|
+
* edit, not a re-declaration.
|
|
330
|
+
*
|
|
331
|
+
* All needed providers run concurrently via `Promise.all`; each one's
|
|
332
|
+
* failure becomes a missing field (handled by the DSL input fallback
|
|
333
|
+
* chain). No provider error propagates to the caller — a single broken
|
|
334
|
+
* source must not blank the bar.
|
|
335
|
+
*
|
|
336
|
+
* [LAW:no-silent-failure][LAW:single-enforcer] Every lane carries a typed
|
|
337
|
+
* outcome (ok | absent | failed) and THIS function is the one log site:
|
|
338
|
+
* `failed` is logged through `deps.log` and projected as a missing field,
|
|
339
|
+
* `absent` is a missing field with nothing to log — the default lives in
|
|
340
|
+
* the DSL declaration's `default` field, owned by the config, not buried
|
|
341
|
+
* here.
|
|
342
|
+
*/
|
|
343
|
+
export async function buildRenderPayload(
|
|
344
|
+
hookData: ClaudeHookData,
|
|
345
|
+
deps: RenderPayloadDeps,
|
|
346
|
+
cwd: string | undefined,
|
|
347
|
+
// [LAW:single-enforcer] The cache pre-computes the closure once at
|
|
348
|
+
// registration; passing it in (rather than recomputing per render) keeps
|
|
349
|
+
// the hot path free of the BFS + extractTemplateRefs cost.
|
|
350
|
+
neededInputPaths: ReadonlySet<string>,
|
|
351
|
+
): Promise<RenderPayload> {
|
|
352
|
+
const wants = (prefix: string): boolean =>
|
|
353
|
+
anyPathStartsWith(neededInputPaths, prefix);
|
|
354
|
+
|
|
355
|
+
// [LAW:dataflow-not-control-flow][LAW:one-type-per-behavior] Every provider
|
|
356
|
+
// lane is ONE shape: "needed → call provider (whose contract is to never
|
|
357
|
+
// reject — the catch makes the lane total against bugs, mapping a throw
|
|
358
|
+
// into the same logged failure path)" or "not needed → ABSENT". The skipped
|
|
359
|
+
// lanes resolve immediately; no variant means lanes are present/absent at
|
|
360
|
+
// the array level (which would change the destructure shape).
|
|
361
|
+
const lane = <T>(
|
|
362
|
+
name: string,
|
|
363
|
+
needed: boolean,
|
|
364
|
+
run: () => Promise<Outcome<T>>,
|
|
365
|
+
): Promise<Outcome<T>> =>
|
|
366
|
+
needed
|
|
367
|
+
? run().catch((e: unknown) => failed(`${name}: ${String(e)}`))
|
|
368
|
+
: Promise.resolve(ABSENT);
|
|
369
|
+
|
|
370
|
+
const [gitOutcome, usage, today, context, metrics, tmuxSession, cacheExpiry] =
|
|
371
|
+
await Promise.all([
|
|
372
|
+
lane("git", wants("git"), () =>
|
|
373
|
+
deps.gitProvider.getGitInfo(
|
|
374
|
+
cwd ?? hookData.workspace?.current_dir,
|
|
375
|
+
gitOptionsFromClosure(neededInputPaths),
|
|
376
|
+
hookData.workspace?.project_dir,
|
|
377
|
+
),
|
|
378
|
+
),
|
|
379
|
+
lane("session", wants("session.cost") || wants("session.tokens"), () =>
|
|
380
|
+
deps.usageStore.getUsageInfo(hookData.session_id, hookData),
|
|
381
|
+
),
|
|
382
|
+
lane("today", wants("today"), () =>
|
|
383
|
+
deps.usageStore.getTodayInfo(hookData),
|
|
384
|
+
),
|
|
385
|
+
lane("context", wants("context"), () =>
|
|
386
|
+
deps.contextProvider.getContextInfo(hookData),
|
|
387
|
+
),
|
|
388
|
+
lane("metrics", wants("metrics"), () =>
|
|
389
|
+
deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
|
|
390
|
+
),
|
|
391
|
+
lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
|
|
392
|
+
// Prompt-cache expiry: a bounded tail-read through the gated transcript-fs
|
|
393
|
+
// seam, so it runs alongside the other providers and stays in the shared
|
|
394
|
+
// in-flight budget rather than blocking the event loop on sync fs.
|
|
395
|
+
lane("cache", wants("cache"), () =>
|
|
396
|
+
cacheExpiresAt(hookData.transcript_path),
|
|
397
|
+
),
|
|
398
|
+
]);
|
|
399
|
+
// [LAW:effects-at-boundaries] The projections are pure folds returning data
|
|
400
|
+
// (payload fragment + failure descriptions); the log effect happens once,
|
|
401
|
+
// here, at the edge. `take` is the total fold for the single-value lanes:
|
|
402
|
+
// ok → value, absent → undefined, failed → undefined + a failure to log.
|
|
403
|
+
const failures: string[] = [];
|
|
404
|
+
const take = <T>(oc: Outcome<T>): T | undefined => {
|
|
405
|
+
if (oc.kind === "failed") {
|
|
406
|
+
failures.push(oc.reason);
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
return oc.kind === "ok" ? oc.value : undefined;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const gitProjection = projectGitInfo(gitOutcome);
|
|
413
|
+
failures.push(...gitProjection.failures);
|
|
414
|
+
const usageValue = take(usage);
|
|
415
|
+
const todayValue = take(today);
|
|
416
|
+
const contextValue = take(context);
|
|
417
|
+
const metricsValue = take(metrics);
|
|
418
|
+
const tmuxValue = take(tmuxSession);
|
|
419
|
+
const cacheValue = take(cacheExpiry);
|
|
420
|
+
for (const f of failures) deps.log("warn", `provider fetch failed: ${f}`);
|
|
421
|
+
// [LAW:dataflow-not-control-flow] block.* reads straight from hookData
|
|
422
|
+
// alongside weekly. (The prior dedicated provider only re-derived
|
|
423
|
+
// `minutesUntilReset(resets_at)`, which the DSL template composes via
|
|
424
|
+
// the formatter func — a duplicate code path was retired.)
|
|
425
|
+
const fiveHour = hookData.rate_limits?.five_hour;
|
|
426
|
+
|
|
427
|
+
// [LAW:one-source-of-truth] The theme variable surfaces the session's
|
|
428
|
+
// resolved theme so the toolbar/tray DSL templates can encode it into
|
|
429
|
+
// cc-candybar:// URLs without re-resolving. SessionState owns the value;
|
|
430
|
+
// we mirror it onto the payload, not redeclare it.
|
|
431
|
+
const themeRaw = wants("theme")
|
|
432
|
+
? deps.sessionState.get(hookData.session_id, "theme")
|
|
433
|
+
: undefined;
|
|
434
|
+
const theme = typeof themeRaw === "string" ? themeRaw : undefined;
|
|
435
|
+
|
|
436
|
+
// home is always available — it's a single env-var read, no I/O cost.
|
|
437
|
+
// Letting the gate skip it would add a branch with no win.
|
|
438
|
+
// [LAW:single-enforcer] All path-shaped payload fields are normalized
|
|
439
|
+
// to forward-slash separators at this boundary. The DSL's directory
|
|
440
|
+
// template (and any user template that does prefix/relative-path math)
|
|
441
|
+
// assumes POSIX separators; without normalization, Windows hookData
|
|
442
|
+
// (current_dir = "C:\Users\Alice") would never match a forward-slash
|
|
443
|
+
// home prefix. Normalize *here*, not in the template — keeps DSL
|
|
444
|
+
// templates platform-agnostic by construction.
|
|
445
|
+
const home = posixify(process.env.HOME ?? process.env.USERPROFILE);
|
|
446
|
+
const workspace = hookData.workspace
|
|
447
|
+
? {
|
|
448
|
+
...hookData.workspace,
|
|
449
|
+
current_dir: posixify(hookData.workspace.current_dir) ?? "",
|
|
450
|
+
project_dir: posixify(hookData.workspace.project_dir) ?? "",
|
|
451
|
+
}
|
|
452
|
+
: hookData.workspace;
|
|
453
|
+
|
|
454
|
+
// [LAW:types-are-the-program] Partial projections so absent provider data
|
|
455
|
+
// travels as missing fields all the way to applyInput. Each provider's
|
|
456
|
+
// null sub-fields become absent keys here; applyInput's fallback chain
|
|
457
|
+
// fills in the declared DSL default and records a last_error per field.
|
|
458
|
+
const sessionPayload: SessionPayload | undefined =
|
|
459
|
+
usageValue === undefined
|
|
460
|
+
? undefined
|
|
461
|
+
: pickNonNull({
|
|
462
|
+
cost: usageValue.session.cost,
|
|
463
|
+
tokens: usageValue.session.tokens,
|
|
464
|
+
});
|
|
465
|
+
const todayPayload: TodayPayload | undefined =
|
|
466
|
+
todayValue === undefined
|
|
467
|
+
? undefined
|
|
468
|
+
: { cost: todayValue.cost, tokens: todayValue.tokens };
|
|
469
|
+
const metricsPayload: MetricsPayload | undefined =
|
|
470
|
+
metricsValue === undefined
|
|
471
|
+
? undefined
|
|
472
|
+
: pickNonNull({
|
|
473
|
+
lastResponseTime: metricsValue.lastResponseTime,
|
|
474
|
+
responseTime: metricsValue.responseTime,
|
|
475
|
+
sessionDuration: metricsValue.sessionDuration,
|
|
476
|
+
messageCount: metricsValue.messageCount,
|
|
477
|
+
linesAdded: metricsValue.linesAdded,
|
|
478
|
+
linesRemoved: metricsValue.linesRemoved,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
...hookData,
|
|
483
|
+
...(workspace !== undefined && { workspace }),
|
|
484
|
+
...(home !== undefined && { home }),
|
|
485
|
+
...(gitProjection.git !== undefined && { git: gitProjection.git }),
|
|
486
|
+
...(tmuxValue !== undefined && { tmux: { session: tmuxValue } }),
|
|
487
|
+
...(theme !== undefined && { theme }),
|
|
488
|
+
...(sessionPayload !== undefined && { session: sessionPayload }),
|
|
489
|
+
...(todayPayload !== undefined && { today: todayPayload }),
|
|
490
|
+
...(wants("block") &&
|
|
491
|
+
fiveHour !== undefined && {
|
|
492
|
+
block: {
|
|
493
|
+
// [LAW:one-source-of-truth] Both fields read straight from the
|
|
494
|
+
// hookData rate-limit window; the DSL composes minutesUntilReset
|
|
495
|
+
// against .block.resetsAt the same way weekly does. One
|
|
496
|
+
// projection rule, two segments.
|
|
497
|
+
nativeUtilization: fiveHour.used_percentage,
|
|
498
|
+
resetsAt: fiveHour.resets_at,
|
|
499
|
+
},
|
|
500
|
+
}),
|
|
501
|
+
...(hookData.rate_limits?.seven_day !== undefined && {
|
|
502
|
+
weekly: {
|
|
503
|
+
percentage: hookData.rate_limits.seven_day.used_percentage,
|
|
504
|
+
resetsAt: hookData.rate_limits.seven_day.resets_at,
|
|
505
|
+
},
|
|
506
|
+
}),
|
|
507
|
+
...(cacheValue !== undefined && {
|
|
508
|
+
cache: { expiresAt: cacheValue },
|
|
509
|
+
}),
|
|
510
|
+
...(contextValue !== undefined && {
|
|
511
|
+
context: {
|
|
512
|
+
totalTokens: contextValue.totalTokens,
|
|
513
|
+
contextLeft: contextValue.contextLeftPercentage,
|
|
514
|
+
},
|
|
515
|
+
}),
|
|
516
|
+
...(metricsPayload !== undefined && { metrics: metricsPayload }),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// [LAW:types-are-the-program] Project an object with possibly-null fields
|
|
521
|
+
// down to a partial whose nulls have been dropped. If every field is null
|
|
522
|
+
// the result is undefined — caller treats that as "provider returned but
|
|
523
|
+
// had nothing usable" and omits the sub-object entirely.
|
|
524
|
+
function pickNonNull<T extends Readonly<Record<string, number | null>>>(
|
|
525
|
+
src: T,
|
|
526
|
+
): { [K in keyof T]?: number } | undefined {
|
|
527
|
+
const out: { [K in keyof T]?: number } = {};
|
|
528
|
+
let any = false;
|
|
529
|
+
for (const k of Object.keys(src) as Array<keyof T>) {
|
|
530
|
+
const v = src[k];
|
|
531
|
+
if (v !== null && v !== undefined) {
|
|
532
|
+
out[k] = v;
|
|
533
|
+
any = true;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return any ? out : undefined;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// [LAW:single-enforcer] Convert backslash-separator path strings to
|
|
540
|
+
// forward-slash separators so DSL templates can rely on POSIX path math
|
|
541
|
+
// (prefix checks, trimPrefix) on every platform. Undefined and empty
|
|
542
|
+
// inputs pass through unchanged. The function is platform-conditional
|
|
543
|
+
// only on whether `\` *could* be a separator (it never can on POSIX, so
|
|
544
|
+
// no harm in always converting — but the explicit guard avoids touching
|
|
545
|
+
// strings on non-Windows callers where backslash is meaningful
|
|
546
|
+
// inside path components).
|
|
547
|
+
function posixify(s: string | undefined): string | undefined {
|
|
548
|
+
if (s === undefined || s.length === 0) return s;
|
|
549
|
+
if (path.sep !== "\\") return s;
|
|
550
|
+
return s.replace(/\\/g, "/");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// [LAW:types-are-the-program] Project the outcome-carrying GitInfo down to
|
|
554
|
+
// the flat shape the DSL input paths read. A pure fold: `ok` fields become
|
|
555
|
+
// values, `absent` and `failed` fields become MISSING keys (the DSL input
|
|
556
|
+
// fallback chain fills the declared default and records a last_error), and
|
|
557
|
+
// every `failed` contributes a description for the boundary to log — this
|
|
558
|
+
// function performs no effect itself ([LAW:effects-at-boundaries]).
|
|
559
|
+
function projectGitInfo(outcome: Outcome<GitInfo>): {
|
|
560
|
+
readonly git?: GitPayload;
|
|
561
|
+
readonly failures: readonly string[];
|
|
562
|
+
} {
|
|
563
|
+
if (outcome.kind === "absent") return { failures: [] };
|
|
564
|
+
if (outcome.kind === "failed") return { failures: [outcome.reason] };
|
|
565
|
+
|
|
566
|
+
const info = outcome.value;
|
|
567
|
+
const failures: string[] = [];
|
|
568
|
+
const field = <T>(
|
|
569
|
+
name: string,
|
|
570
|
+
oc: Outcome<T> | undefined,
|
|
571
|
+
): T | undefined => {
|
|
572
|
+
if (oc === undefined || oc.kind === "absent") return undefined;
|
|
573
|
+
if (oc.kind === "failed") {
|
|
574
|
+
failures.push(`git.${name}: ${oc.reason}`);
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
return oc.value;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const aheadBehind = field("aheadBehind", info.aheadBehind);
|
|
581
|
+
const sha = field("sha", info.sha);
|
|
582
|
+
const operation = field("operation", info.operation);
|
|
583
|
+
const timeSinceCommit = field("timeSinceCommit", info.timeSinceCommit);
|
|
584
|
+
const stash = field("stash", info.stashCount);
|
|
585
|
+
const upstream = field("upstream", info.upstream);
|
|
586
|
+
const repoName = field("repoName", info.repoName);
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
git: {
|
|
590
|
+
branch: info.branch,
|
|
591
|
+
status: info.status,
|
|
592
|
+
...(aheadBehind !== undefined && {
|
|
593
|
+
ahead: aheadBehind.ahead,
|
|
594
|
+
behind: aheadBehind.behind,
|
|
595
|
+
}),
|
|
596
|
+
...(info.workingTree !== undefined && {
|
|
597
|
+
staged: info.workingTree.staged,
|
|
598
|
+
unstaged: info.workingTree.unstaged,
|
|
599
|
+
untracked: info.workingTree.untracked,
|
|
600
|
+
conflicts: info.workingTree.conflicts,
|
|
601
|
+
}),
|
|
602
|
+
...(sha !== undefined && { sha }),
|
|
603
|
+
...(operation !== undefined && { operation }),
|
|
604
|
+
...(timeSinceCommit !== undefined && { timeSinceCommit }),
|
|
605
|
+
...(stash !== undefined && { stash }),
|
|
606
|
+
...(upstream !== undefined && { upstream }),
|
|
607
|
+
...(repoName !== undefined && { repoName }),
|
|
608
|
+
},
|
|
609
|
+
failures,
|
|
610
|
+
};
|
|
611
|
+
}
|