@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.1.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.1.1",
95
- "@promptctl/cc-candybar-darwin-x64": "1.1.1",
96
- "@promptctl/cc-candybar-linux-x64": "1.1.1",
97
- "@promptctl/cc-candybar-linux-arm64": "1.1.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
- lane("session", wants("session.cost") || wants("session.tokens"), () =>
380
- deps.usageStore.getUsageInfo(hookData.session_id, hookData),
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
- ...(hookData.rate_limits?.seven_day !== undefined && {
612
+ ...(sevenDay !== undefined && {
502
613
  weekly: {
503
- percentage: hookData.rate_limits.seven_day.used_percentage,
504
- resetsAt: hookData.rate_limits.seven_day.resets_at,
614
+ percentage: sevenDay.used_percentage,
615
+ resetsAt: sevenDay.resets_at,
616
+ ...(weeklyEta !== undefined && { etaMinutes: weeklyEta }),
505
617
  },
506
618
  }),
507
619
  ...(cacheValue !== undefined && {
@@ -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 {