@promptctl/cc-candybar 1.1.1 → 1.2.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/dist/index.mjs +65 -65
- package/package.json +5 -5
- package/src/config/default-dsl-config.ts +79 -0
- package/src/daemon/render-payload.ts +118 -6
- package/src/daemon/server.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -91,10 +91,10 @@
|
|
|
91
91
|
"mobx": "^6.15.0"
|
|
92
92
|
},
|
|
93
93
|
"optionalDependencies": {
|
|
94
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.2.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.2.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.2.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.2.0"
|
|
98
98
|
},
|
|
99
99
|
"pnpm": {
|
|
100
100
|
"supportedArchitectures": {
|
|
@@ -108,6 +108,29 @@ function blockLikeFg(pctRef: string): string {
|
|
|
108
108
|
);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// [LAW:dataflow-not-control-flow] The burn segment heats as the cap NEARS, so
|
|
112
|
+
// the cascade reads the projected minutes-to-cap (smaller = hotter), the
|
|
113
|
+
// inverse direction of blockLikeBg's fuller-is-hotter. The not-projectable
|
|
114
|
+
// sentinel (-1, sorts below every threshold) is caught first so "we cannot
|
|
115
|
+
// project" colors calm, never error. Thresholds are var refs so a user
|
|
116
|
+
// overrides them through the same by-name variables cascade.
|
|
117
|
+
function etaHeatBg(etaRef: string, warnRef: string, errRef: string): string {
|
|
118
|
+
return (
|
|
119
|
+
`{{ if lt ${etaRef} 0 }}panel` +
|
|
120
|
+
`{{ else }}{{ if lt ${etaRef} ${errRef} }}error` +
|
|
121
|
+
`{{ else }}{{ if lt ${etaRef} ${warnRef} }}warning` +
|
|
122
|
+
`{{ else }}panel{{ end }}{{ end }}{{ end }}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function etaHeatFg(etaRef: string, warnRef: string): string {
|
|
127
|
+
return (
|
|
128
|
+
`{{ if lt ${etaRef} 0 }}foreground` +
|
|
129
|
+
`{{ else }}{{ if lt ${etaRef} ${warnRef} }}button-color-foreground` +
|
|
130
|
+
`{{ else }}foreground{{ end }}{{ end }}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
111
134
|
// ─── The default config ──────────────────────────────────────────────────────
|
|
112
135
|
|
|
113
136
|
export const DEFAULT_DSL_CONFIG = {
|
|
@@ -327,6 +350,34 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
327
350
|
},
|
|
328
351
|
"weekly.budget.warningThreshold": { kind: "literal", value: 80 },
|
|
329
352
|
|
|
353
|
+
// Burn rate + cap projection — daemon-derived (see render-payload.ts).
|
|
354
|
+
// Each projection is ABSENT when not projectable; the var-system fills the
|
|
355
|
+
// -1 default, a structurally-impossible value the burnrate helpers read as
|
|
356
|
+
// "—" [LAW:no-silent-failure] (0 minutes / $0-per-hr are real, displayable
|
|
357
|
+
// values, so they cannot double as the absence marker).
|
|
358
|
+
"burn.costPerHour": {
|
|
359
|
+
kind: "input",
|
|
360
|
+
path: "burn.costPerHour",
|
|
361
|
+
type: "number",
|
|
362
|
+
default: -1,
|
|
363
|
+
},
|
|
364
|
+
"block.etaMinutes": {
|
|
365
|
+
kind: "input",
|
|
366
|
+
path: "block.etaMinutes",
|
|
367
|
+
type: "number",
|
|
368
|
+
default: -1,
|
|
369
|
+
},
|
|
370
|
+
"weekly.etaMinutes": {
|
|
371
|
+
kind: "input",
|
|
372
|
+
path: "weekly.etaMinutes",
|
|
373
|
+
type: "number",
|
|
374
|
+
default: -1,
|
|
375
|
+
},
|
|
376
|
+
// ETA-heat thresholds (minutes-to-cap) — overridable per-config through the
|
|
377
|
+
// variables-merge-by-name cascade, like the *.budget.warningThreshold knobs.
|
|
378
|
+
"burn.eta.warnMinutes": { kind: "literal", value: 60 },
|
|
379
|
+
"burn.eta.errorMinutes": { kind: "literal", value: 30 },
|
|
380
|
+
|
|
330
381
|
// Context — daemon fetches via ContextProvider; contextLeftPercentage.
|
|
331
382
|
"context.totalTokens": {
|
|
332
383
|
kind: "input",
|
|
@@ -486,6 +537,24 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
486
537
|
fg: blockLikeFg(".weekly.percentage"),
|
|
487
538
|
when: "{{ gt .weekly.resetsAt 0 }}",
|
|
488
539
|
},
|
|
540
|
+
// Burn rate + cap projection: "$X/hr · Nm to 5h · Nd to wk". The headline
|
|
541
|
+
// number of a usage monitor — how fast you are spending and when you hit
|
|
542
|
+
// the wall. All math is daemon-side (render-payload.ts); the template only
|
|
543
|
+
// formats. Heats as the 5h cap nears (etaHeat*). Shown when either
|
|
544
|
+
// rate-limit window is active — the same signal block/weekly gate on.
|
|
545
|
+
burnrate: {
|
|
546
|
+
template:
|
|
547
|
+
' ⚡ {{ template "formatRate" .burn.costPerHour }} · ' +
|
|
548
|
+
'{{ template "formatEta" .block.etaMinutes }} to 5h · ' +
|
|
549
|
+
'{{ template "formatEta" .weekly.etaMinutes }} to wk ',
|
|
550
|
+
bg: etaHeatBg(
|
|
551
|
+
".block.etaMinutes",
|
|
552
|
+
".burn.eta.warnMinutes",
|
|
553
|
+
".burn.eta.errorMinutes",
|
|
554
|
+
),
|
|
555
|
+
fg: etaHeatFg(".block.etaMinutes", ".burn.eta.warnMinutes"),
|
|
556
|
+
when: "{{ or (gt .block.resetsAt 0) (gt .weekly.resetsAt 0) }}",
|
|
557
|
+
},
|
|
489
558
|
// Prompt-cache warmth countdown. minutesUntilReset clamps a past expiry
|
|
490
559
|
// to 0, so an expired cache renders "cold" (and reads red via the ≤8
|
|
491
560
|
// arm) rather than a negative number. [LAW:dataflow-not-control-flow]
|
|
@@ -592,6 +661,16 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
592
661
|
'{{ else if ge . 1000 }}{{ printf "%.1f" (divf . 1000) }}K' +
|
|
593
662
|
"{{ else }}{{ . }}{{ end }}",
|
|
594
663
|
formatTokens: '{{ template "formatTokenCount" . }} tokens',
|
|
664
|
+
// Burn rate: "$X.XX/hr" when projectable, "—/hr" otherwise. The daemon
|
|
665
|
+
// emits -1 (a structurally-impossible rate) for not-projectable, so the
|
|
666
|
+
// branch reads a VALUE, never a hidden control-flow flag. Reuses formatCost
|
|
667
|
+
// so the dollar policy has one home.
|
|
668
|
+
formatRate:
|
|
669
|
+
'{{ if lt . 0 }}—/hr{{ else }}{{ template "formatCost" . }}/hr{{ end }}',
|
|
670
|
+
// ETA to a rate-limit cap: humanized minutes when projectable, "—" when the
|
|
671
|
+
// daemon could not project (-1 sentinel). Reuses the long-remaining cascade.
|
|
672
|
+
formatEta:
|
|
673
|
+
'{{ if lt . 0 }}—{{ else }}{{ template "formatLongTimeRemaining" . }}{{ end }}',
|
|
595
674
|
// Breakdown over a dict {input, output, cacheCreation, cacheRead}; each present
|
|
596
675
|
// part is formatted by the shared formatTokenCount and joined with " + ". A
|
|
597
676
|
// `$first` flag (reassigned across if-frames) inserts the separator before all
|
|
@@ -64,6 +64,7 @@ export interface RenderPayload extends ClaudeHookData {
|
|
|
64
64
|
// exactly like a missing top-level field.
|
|
65
65
|
readonly session?: SessionPayload;
|
|
66
66
|
readonly today?: TodayPayload;
|
|
67
|
+
readonly burn?: BurnPayload;
|
|
67
68
|
readonly block?: BlockPayload;
|
|
68
69
|
readonly weekly?: WeeklyPayload;
|
|
69
70
|
readonly cache?: CachePayload;
|
|
@@ -105,14 +106,31 @@ export interface TodayPayload {
|
|
|
105
106
|
readonly tokens?: number;
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
// [LAW:one-type-per-behavior] The burn rate is the session's spend velocity —
|
|
110
|
+
// dollars per wall-clock hour — a derivative of the same cost the `session`
|
|
111
|
+
// segment totals, so it is its own concept, not a field bolted onto the
|
|
112
|
+
// totals. Optional because a too-young session yields no honest rate
|
|
113
|
+
// ([LAW:no-silent-failure] — absence over a single-turn artifact).
|
|
114
|
+
export interface BurnPayload {
|
|
115
|
+
readonly costPerHour?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
108
118
|
export interface BlockPayload {
|
|
109
119
|
readonly nativeUtilization: number;
|
|
110
120
|
readonly resetsAt: number;
|
|
121
|
+
// [LAW:types-are-the-program] Linear projection of nativeUtilization → 100%
|
|
122
|
+
// at the current rate, in whole minutes. Absent (not 0, not a sentinel
|
|
123
|
+
// in the type) when the window is too young or shows no usage to project
|
|
124
|
+
// from — the ETA's "we cannot say" state is unrepresentable as a number,
|
|
125
|
+
// so it travels as a missing field to the DSL default. [LAW:no-silent-failure]
|
|
126
|
+
readonly etaMinutes?: number;
|
|
111
127
|
}
|
|
112
128
|
|
|
113
129
|
export interface WeeklyPayload {
|
|
114
130
|
readonly percentage: number;
|
|
115
131
|
readonly resetsAt: number;
|
|
132
|
+
// Same projection as BlockPayload.etaMinutes over the seven-day window.
|
|
133
|
+
readonly etaMinutes?: number;
|
|
116
134
|
}
|
|
117
135
|
|
|
118
136
|
// Prompt-cache warmth. One field — the epoch-seconds expiry instant —
|
|
@@ -158,6 +176,64 @@ export interface RenderPayloadDeps {
|
|
|
158
176
|
// buildRenderPayload is the ONE place lane failures are logged, so the
|
|
159
177
|
// providers' interiors never log and never double-log.
|
|
160
178
|
readonly log: DaemonLogger;
|
|
179
|
+
// [LAW:single-enforcer] The one clock the projection math reads "now" from —
|
|
180
|
+
// the same seam threaded to the template engine's `minutesUntilReset`, so
|
|
181
|
+
// an ETA and the reset countdown beside it agree on the instant. Omitted ⇒
|
|
182
|
+
// wall clock; tests inject a frozen clock for determinism.
|
|
183
|
+
readonly clock?: () => Date;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Rate-limit projection (pure) ──────────────────────────────────────────────
|
|
187
|
+
//
|
|
188
|
+
// [LAW:effects-at-boundaries] The math is pure — utilization, reset instant,
|
|
189
|
+
// window length and `now` in; minutes-to-cap out. The only effect (reading the
|
|
190
|
+
// clock) stays in buildRenderPayload; these stay testable in isolation.
|
|
191
|
+
|
|
192
|
+
// Window lengths are facts of Claude's rate-limit cadence, not config: the
|
|
193
|
+
// five-hour block and the seven-day window.
|
|
194
|
+
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
|
195
|
+
const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
196
|
+
// Below this much elapsed in a window, a linear projection from one early data
|
|
197
|
+
// point is noise — surface no ETA rather than a confidently-wrong number.
|
|
198
|
+
const MIN_PROJECTABLE_ELAPSED_MS = 5 * 60 * 1000;
|
|
199
|
+
// The same floor for the spend rate: under a minute of session wall-clock,
|
|
200
|
+
// $/hr is dominated by a single turn rather than a sustained burn.
|
|
201
|
+
const MIN_BURN_SECONDS = 60;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Linearly extrapolate a rate-limit window's utilization to its 100% cap.
|
|
205
|
+
* The window started `windowMs` before `resetsAtSec`; elapsed time and the
|
|
206
|
+
* used-% give a rate, and the remaining headroom divided by that rate is the
|
|
207
|
+
* minutes-to-cap. Returns undefined when the window is too young to project
|
|
208
|
+
* or shows no usage yet — the caller drops the field and the segment renders
|
|
209
|
+
* "—" rather than a fabricated ETA. [LAW:no-silent-failure]
|
|
210
|
+
*/
|
|
211
|
+
export function projectEtaMinutes(
|
|
212
|
+
usedPercentage: number,
|
|
213
|
+
resetsAtSec: number,
|
|
214
|
+
windowMs: number,
|
|
215
|
+
nowMs: number,
|
|
216
|
+
): number | undefined {
|
|
217
|
+
const elapsedMs = windowMs - (resetsAtSec * 1000 - nowMs);
|
|
218
|
+
if (elapsedMs < MIN_PROJECTABLE_ELAPSED_MS || usedPercentage <= 0)
|
|
219
|
+
return undefined;
|
|
220
|
+
const pctPerMs = usedPercentage / elapsedMs;
|
|
221
|
+
const etaMs = (100 - usedPercentage) / pctPerMs;
|
|
222
|
+
return Math.max(0, Math.round(etaMs / 60000));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Session spend rate in dollars per hour: cost over wall-clock duration.
|
|
227
|
+
* Returns undefined under a wall-clock floor where the rate is a single-turn
|
|
228
|
+
* artifact, not a sustained burn. A real $0 over enough time is a true 0/hr,
|
|
229
|
+
* not absence. [LAW:no-silent-failure]
|
|
230
|
+
*/
|
|
231
|
+
export function projectCostPerHour(
|
|
232
|
+
cost: number,
|
|
233
|
+
durationSeconds: number,
|
|
234
|
+
): number | undefined {
|
|
235
|
+
if (durationSeconds < MIN_BURN_SECONDS) return undefined;
|
|
236
|
+
return (cost * 3600) / durationSeconds;
|
|
161
237
|
}
|
|
162
238
|
|
|
163
239
|
// ─── Builder ─────────────────────────────────────────────────────────────────
|
|
@@ -376,8 +452,13 @@ export async function buildRenderPayload(
|
|
|
376
452
|
hookData.workspace?.project_dir,
|
|
377
453
|
),
|
|
378
454
|
),
|
|
379
|
-
|
|
380
|
-
|
|
455
|
+
// [LAW:dataflow-not-control-flow] The burn segment reads `burn.costPerHour`,
|
|
456
|
+
// a derivative of session cost and metrics duration — so wanting `burn`
|
|
457
|
+
// pulls in exactly the two lanes it is folded from.
|
|
458
|
+
lane(
|
|
459
|
+
"session",
|
|
460
|
+
wants("session.cost") || wants("session.tokens") || wants("burn"),
|
|
461
|
+
() => deps.usageStore.getUsageInfo(hookData.session_id, hookData),
|
|
381
462
|
),
|
|
382
463
|
lane("today", wants("today"), () =>
|
|
383
464
|
deps.usageStore.getTodayInfo(hookData),
|
|
@@ -385,7 +466,7 @@ export async function buildRenderPayload(
|
|
|
385
466
|
lane("context", wants("context"), () =>
|
|
386
467
|
deps.contextProvider.getContextInfo(hookData),
|
|
387
468
|
),
|
|
388
|
-
lane("metrics", wants("metrics"), () =>
|
|
469
|
+
lane("metrics", wants("metrics") || wants("burn"), () =>
|
|
389
470
|
deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
|
|
390
471
|
),
|
|
391
472
|
lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
|
|
@@ -423,6 +504,34 @@ export async function buildRenderPayload(
|
|
|
423
504
|
// `minutesUntilReset(resets_at)`, which the DSL template composes via
|
|
424
505
|
// the formatter func — a duplicate code path was retired.)
|
|
425
506
|
const fiveHour = hookData.rate_limits?.five_hour;
|
|
507
|
+
const sevenDay = hookData.rate_limits?.seven_day;
|
|
508
|
+
// [LAW:single-enforcer] One clock read feeds every projection this render.
|
|
509
|
+
const nowMs = (deps.clock ?? (() => new Date()))().getTime();
|
|
510
|
+
const blockEta = fiveHour
|
|
511
|
+
? projectEtaMinutes(
|
|
512
|
+
fiveHour.used_percentage,
|
|
513
|
+
fiveHour.resets_at,
|
|
514
|
+
FIVE_HOUR_MS,
|
|
515
|
+
nowMs,
|
|
516
|
+
)
|
|
517
|
+
: undefined;
|
|
518
|
+
const weeklyEta = sevenDay
|
|
519
|
+
? projectEtaMinutes(
|
|
520
|
+
sevenDay.used_percentage,
|
|
521
|
+
sevenDay.resets_at,
|
|
522
|
+
SEVEN_DAY_MS,
|
|
523
|
+
nowMs,
|
|
524
|
+
)
|
|
525
|
+
: undefined;
|
|
526
|
+
// [LAW:dataflow-not-control-flow] Gated by `wants("burn")` so the rate is
|
|
527
|
+
// computed only when a layout segment reads it; absent cost/duration (lane
|
|
528
|
+
// skipped or provider empty) yields no rate, never a fabricated one.
|
|
529
|
+
const burnCost = usageValue?.session.cost;
|
|
530
|
+
const burnDuration = metricsValue?.sessionDuration;
|
|
531
|
+
const costPerHour =
|
|
532
|
+
wants("burn") && burnCost != null && burnDuration != null
|
|
533
|
+
? projectCostPerHour(burnCost, burnDuration)
|
|
534
|
+
: undefined;
|
|
426
535
|
|
|
427
536
|
// [LAW:one-source-of-truth] The theme variable surfaces the session's
|
|
428
537
|
// resolved theme so the toolbar/tray DSL templates can encode it into
|
|
@@ -487,6 +596,7 @@ export async function buildRenderPayload(
|
|
|
487
596
|
...(theme !== undefined && { theme }),
|
|
488
597
|
...(sessionPayload !== undefined && { session: sessionPayload }),
|
|
489
598
|
...(todayPayload !== undefined && { today: todayPayload }),
|
|
599
|
+
...(costPerHour !== undefined && { burn: { costPerHour } }),
|
|
490
600
|
...(wants("block") &&
|
|
491
601
|
fiveHour !== undefined && {
|
|
492
602
|
block: {
|
|
@@ -496,12 +606,14 @@ export async function buildRenderPayload(
|
|
|
496
606
|
// projection rule, two segments.
|
|
497
607
|
nativeUtilization: fiveHour.used_percentage,
|
|
498
608
|
resetsAt: fiveHour.resets_at,
|
|
609
|
+
...(blockEta !== undefined && { etaMinutes: blockEta }),
|
|
499
610
|
},
|
|
500
611
|
}),
|
|
501
|
-
...(
|
|
612
|
+
...(sevenDay !== undefined && {
|
|
502
613
|
weekly: {
|
|
503
|
-
percentage:
|
|
504
|
-
resetsAt:
|
|
614
|
+
percentage: sevenDay.used_percentage,
|
|
615
|
+
resetsAt: sevenDay.resets_at,
|
|
616
|
+
...(weeklyEta !== undefined && { etaMinutes: weeklyEta }),
|
|
505
617
|
},
|
|
506
618
|
}),
|
|
507
619
|
...(cacheValue !== undefined && {
|
package/src/daemon/server.ts
CHANGED
|
@@ -1076,6 +1076,9 @@ const payloadDeps = {
|
|
|
1076
1076
|
// [LAW:single-enforcer] buildRenderPayload is the one log site for the
|
|
1077
1077
|
// outcome-carrying provider lanes (git, cache).
|
|
1078
1078
|
log: dlog,
|
|
1079
|
+
// [LAW:single-enforcer] The daemon's wall clock — the same instant source
|
|
1080
|
+
// the rate-limit ETA projection and the template's reset countdown read.
|
|
1081
|
+
clock: () => new Date(),
|
|
1079
1082
|
};
|
|
1080
1083
|
|
|
1081
1084
|
function handleClick(verb: string, value: string): Response {
|