@promptctl/cc-candybar 1.1.1 → 1.3.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 +117 -8
- package/src/daemon/cache/render.ts +3 -4
- package/src/daemon/render-payload.ts +118 -6
- package/src/daemon/server.ts +3 -0
- package/src/demo/dsl.ts +0 -1
- package/src/dsl/render.ts +6 -5
- package/src/render/action.ts +5 -6
- package/src/render/picker.ts +0 -5
- package/src/var-system/sources.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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.3.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.3.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.3.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.3.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 = {
|
|
@@ -136,6 +159,14 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
136
159
|
path: "workspace.project_dir",
|
|
137
160
|
default: "",
|
|
138
161
|
},
|
|
162
|
+
// Transcript path (a top-level hookData field, spread onto the payload
|
|
163
|
+
// root by buildRenderPayload). Read by the quick-action tray's
|
|
164
|
+
// openTranscript action — pass-through, no projection.
|
|
165
|
+
transcript_path: {
|
|
166
|
+
kind: "input",
|
|
167
|
+
path: "transcript_path",
|
|
168
|
+
default: "",
|
|
169
|
+
},
|
|
139
170
|
"model.display_name": {
|
|
140
171
|
kind: "input",
|
|
141
172
|
path: "model.display_name",
|
|
@@ -327,6 +358,34 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
327
358
|
},
|
|
328
359
|
"weekly.budget.warningThreshold": { kind: "literal", value: 80 },
|
|
329
360
|
|
|
361
|
+
// Burn rate + cap projection — daemon-derived (see render-payload.ts).
|
|
362
|
+
// Each projection is ABSENT when not projectable; the var-system fills the
|
|
363
|
+
// -1 default, a structurally-impossible value the burnrate helpers read as
|
|
364
|
+
// "—" [LAW:no-silent-failure] (0 minutes / $0-per-hr are real, displayable
|
|
365
|
+
// values, so they cannot double as the absence marker).
|
|
366
|
+
"burn.costPerHour": {
|
|
367
|
+
kind: "input",
|
|
368
|
+
path: "burn.costPerHour",
|
|
369
|
+
type: "number",
|
|
370
|
+
default: -1,
|
|
371
|
+
},
|
|
372
|
+
"block.etaMinutes": {
|
|
373
|
+
kind: "input",
|
|
374
|
+
path: "block.etaMinutes",
|
|
375
|
+
type: "number",
|
|
376
|
+
default: -1,
|
|
377
|
+
},
|
|
378
|
+
"weekly.etaMinutes": {
|
|
379
|
+
kind: "input",
|
|
380
|
+
path: "weekly.etaMinutes",
|
|
381
|
+
type: "number",
|
|
382
|
+
default: -1,
|
|
383
|
+
},
|
|
384
|
+
// ETA-heat thresholds (minutes-to-cap) — overridable per-config through the
|
|
385
|
+
// variables-merge-by-name cascade, like the *.budget.warningThreshold knobs.
|
|
386
|
+
"burn.eta.warnMinutes": { kind: "literal", value: 60 },
|
|
387
|
+
"burn.eta.errorMinutes": { kind: "literal", value: 30 },
|
|
388
|
+
|
|
330
389
|
// Context — daemon fetches via ContextProvider; contextLeftPercentage.
|
|
331
390
|
"context.totalTokens": {
|
|
332
391
|
kind: "input",
|
|
@@ -446,10 +505,16 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
446
505
|
fg: "foreground",
|
|
447
506
|
when: '{{ ne .git.branch "" }}',
|
|
448
507
|
},
|
|
508
|
+
// Quick-action tray — copy the session id / cwd, open the project dir /
|
|
509
|
+
// transcript in the editor. [LAW:locality-or-seam] The glyph is the
|
|
510
|
+
// REPRESENTATION; the named action (below) is the BEHAVIOR; the action
|
|
511
|
+
// name is the seam between them. Re-glyph without touching behavior;
|
|
512
|
+
// re-target without touching this template. Each `{{ action … }}` emits
|
|
513
|
+
// one OSC-8 clickable region whose URL the wire codec owns end-to-end.
|
|
449
514
|
toolbar: {
|
|
450
515
|
template:
|
|
451
|
-
' {{
|
|
452
|
-
' {{
|
|
516
|
+
' {{ action "copySession" "⎘ id" }} {{ action "copyDir" "⎘ cwd" }}' +
|
|
517
|
+
' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }} ',
|
|
453
518
|
bg: "surface",
|
|
454
519
|
fg: "foreground",
|
|
455
520
|
},
|
|
@@ -486,6 +551,24 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
486
551
|
fg: blockLikeFg(".weekly.percentage"),
|
|
487
552
|
when: "{{ gt .weekly.resetsAt 0 }}",
|
|
488
553
|
},
|
|
554
|
+
// Burn rate + cap projection: "$X/hr · Nm to 5h · Nd to wk". The headline
|
|
555
|
+
// number of a usage monitor — how fast you are spending and when you hit
|
|
556
|
+
// the wall. All math is daemon-side (render-payload.ts); the template only
|
|
557
|
+
// formats. Heats as the 5h cap nears (etaHeat*). Shown when either
|
|
558
|
+
// rate-limit window is active — the same signal block/weekly gate on.
|
|
559
|
+
burnrate: {
|
|
560
|
+
template:
|
|
561
|
+
' ⚡ {{ template "formatRate" .burn.costPerHour }} · ' +
|
|
562
|
+
'{{ template "formatEta" .block.etaMinutes }} to 5h · ' +
|
|
563
|
+
'{{ template "formatEta" .weekly.etaMinutes }} to wk ',
|
|
564
|
+
bg: etaHeatBg(
|
|
565
|
+
".block.etaMinutes",
|
|
566
|
+
".burn.eta.warnMinutes",
|
|
567
|
+
".burn.eta.errorMinutes",
|
|
568
|
+
),
|
|
569
|
+
fg: etaHeatFg(".block.etaMinutes", ".burn.eta.warnMinutes"),
|
|
570
|
+
when: "{{ or (gt .block.resetsAt 0) (gt .weekly.resetsAt 0) }}",
|
|
571
|
+
},
|
|
489
572
|
// Prompt-cache warmth countdown. minutesUntilReset clamps a past expiry
|
|
490
573
|
// to 0, so an expired cache renders "cold" (and reads red via the ≤8
|
|
491
574
|
// arm) rather than a negative number. [LAW:dataflow-not-control-flow]
|
|
@@ -543,7 +626,7 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
543
626
|
},
|
|
544
627
|
|
|
545
628
|
// Default layout — a single horizontal row of segment refs.
|
|
546
|
-
// A-grammar equivalent: { h: ["directory","git","model","session","today","context"] }
|
|
629
|
+
// A-grammar equivalent: { h: ["directory","git","model","session","today","context","toolbar"] }
|
|
547
630
|
// [LAW:one-source-of-truth] The bundled default now authors the same terse
|
|
548
631
|
// surface every user config lowers to, so the default is the reference spelling.
|
|
549
632
|
// Adding rows = wrapping in { kind:"container", direction:"vertical", children:[...] };
|
|
@@ -558,14 +641,30 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
558
641
|
{ kind: "segment", name: "session" },
|
|
559
642
|
{ kind: "segment", name: "today" },
|
|
560
643
|
{ kind: "segment", name: "context" },
|
|
644
|
+
{ kind: "segment", name: "toolbar" },
|
|
561
645
|
],
|
|
562
646
|
},
|
|
563
647
|
|
|
564
|
-
// [LAW:locality-or-seam]
|
|
565
|
-
// the
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
|
|
648
|
+
// [LAW:locality-or-seam] The quick-action tray's behaviors, decoupled by NAME
|
|
649
|
+
// from the `toolbar` segment's glyphs above. copy/open evaluate a Go-template
|
|
650
|
+
// against the live render scope at click time and write NO SessionState, so
|
|
651
|
+
// they derive no state validator (no gate) — they are pure click effects.
|
|
652
|
+
//
|
|
653
|
+
// [LAW:single-enforcer] Each template emits a RAW value; the click-wire codec
|
|
654
|
+
// (effectsUrl → encodeSegments) owns ALL percent-encoding and the verb's
|
|
655
|
+
// `oneArg` owns the single matching decode — so the template never hand-rolls
|
|
656
|
+
// a `urlEncode`, and the path round-trips untouched through one codec.
|
|
657
|
+
//
|
|
658
|
+
// open* route through the open-vscode verb (`open -a "Visual Studio Code"
|
|
659
|
+
// <path>`), so they pass a bare filesystem path — a directory or a file the
|
|
660
|
+
// editor opens directly — NOT a `vscode://` URL (which `open -a` would treat
|
|
661
|
+
// as a literal filename, not a deep link).
|
|
662
|
+
actions: {
|
|
663
|
+
copySession: { copy: "{{ .session.id }}" },
|
|
664
|
+
copyDir: { copy: "{{ .current_dir }}" },
|
|
665
|
+
openProject: { open: "{{ .project_dir }}" },
|
|
666
|
+
openTranscript: { open: "{{ .transcript_path }}" },
|
|
667
|
+
},
|
|
569
668
|
|
|
570
669
|
// [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
|
|
571
670
|
// for the cost/token/budget family lives here as named template helpers, each
|
|
@@ -592,6 +691,16 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
592
691
|
'{{ else if ge . 1000 }}{{ printf "%.1f" (divf . 1000) }}K' +
|
|
593
692
|
"{{ else }}{{ . }}{{ end }}",
|
|
594
693
|
formatTokens: '{{ template "formatTokenCount" . }} tokens',
|
|
694
|
+
// Burn rate: "$X.XX/hr" when projectable, "—/hr" otherwise. The daemon
|
|
695
|
+
// emits -1 (a structurally-impossible rate) for not-projectable, so the
|
|
696
|
+
// branch reads a VALUE, never a hidden control-flow flag. Reuses formatCost
|
|
697
|
+
// so the dollar policy has one home.
|
|
698
|
+
formatRate:
|
|
699
|
+
'{{ if lt . 0 }}—/hr{{ else }}{{ template "formatCost" . }}/hr{{ end }}',
|
|
700
|
+
// ETA to a rate-limit cap: humanized minutes when projectable, "—" when the
|
|
701
|
+
// daemon could not project (-1 sentinel). Reuses the long-remaining cascade.
|
|
702
|
+
formatEta:
|
|
703
|
+
'{{ if lt . 0 }}—{{ else }}{{ template "formatLongTimeRemaining" . }}{{ end }}',
|
|
595
704
|
// Breakdown over a dict {input, output, cacheCreation, cacheRead}; each present
|
|
596
705
|
// part is formatted by the shared formatTokenCount and joined with " + ". A
|
|
597
706
|
// `$first` flag (reassigned across if-frames) inserts the separator before all
|
|
@@ -300,12 +300,11 @@ export class RenderCache {
|
|
|
300
300
|
// reloadInto preserves the prior last-known-good with nothing half-installed.
|
|
301
301
|
const validatorDisposers: Array<() => void> = [];
|
|
302
302
|
try {
|
|
303
|
-
// [LAW:
|
|
304
|
-
//
|
|
305
|
-
//
|
|
303
|
+
// [LAW:one-source-of-truth] The action runtime reads session.id + current
|
|
304
|
+
// picker values from registry.variableStore — the same store this entry's
|
|
305
|
+
// registry declares into — so no store reference is threaded separately.
|
|
306
306
|
compiled = registerDslConfig(config, registry, {
|
|
307
307
|
cwd: entry.cwd,
|
|
308
|
-
store,
|
|
309
308
|
});
|
|
310
309
|
// [LAW:one-source-of-truth] Derive the writable-key validators from the
|
|
311
310
|
// config's action table (the sole interaction authority) through one
|
|
@@ -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 {
|
package/src/demo/dsl.ts
CHANGED
package/src/dsl/render.ts
CHANGED
|
@@ -252,7 +252,7 @@ function compileHelperPreamble(
|
|
|
252
252
|
export function registerDslConfig(
|
|
253
253
|
config: ValidatedConfig,
|
|
254
254
|
registry: SourceRegistry,
|
|
255
|
-
opts?: { cwd?: string;
|
|
255
|
+
opts?: { cwd?: string; clock?: () => Date },
|
|
256
256
|
): CompiledConfig {
|
|
257
257
|
const cwd = opts?.cwd ?? process.cwd();
|
|
258
258
|
|
|
@@ -262,11 +262,12 @@ export function registerDslConfig(
|
|
|
262
262
|
// because the action set is config-scoped. The runtime holder is populated below
|
|
263
263
|
// — the `action`/`picker` funcs reference the engine, and the compiled actions
|
|
264
264
|
// reference the engine, so the holder breaks that cycle.
|
|
265
|
-
// [LAW:
|
|
266
|
-
//
|
|
267
|
-
//
|
|
265
|
+
// [LAW:one-source-of-truth] The action runtime reads through the SAME store the
|
|
266
|
+
// registry declares into and the renderer reads back — sourced from the registry
|
|
267
|
+
// itself, not a redundant opts field a caller could forget (or pass a divergent
|
|
268
|
+
// store for). Every config has a registry, so the action store is never null.
|
|
268
269
|
const actionRuntime: ActionRuntime = {
|
|
269
|
-
store:
|
|
270
|
+
store: registry.variableStore,
|
|
270
271
|
compiled: new Map(),
|
|
271
272
|
};
|
|
272
273
|
// [LAW:one-way-deps] Inject action + picker feature funcs as data — the engine
|
package/src/render/action.ts
CHANGED
|
@@ -117,7 +117,11 @@ export function optionDomain(src: OptionSource): readonly string[] {
|
|
|
117
117
|
// reads session.id and the current value from the same source the rest of the
|
|
118
118
|
// render does.
|
|
119
119
|
export interface ActionRuntime {
|
|
120
|
-
|
|
120
|
+
// [LAW:types-are-the-program] Always present — registerDslConfig sources it
|
|
121
|
+
// from the registry it is handed (registry.variableStore), so "no store" is
|
|
122
|
+
// structurally unrepresentable. The action reads session.id and current
|
|
123
|
+
// values from the same store the renderer reads.
|
|
124
|
+
store: VariableStore;
|
|
121
125
|
compiled: CompiledActions;
|
|
122
126
|
}
|
|
123
127
|
|
|
@@ -416,11 +420,6 @@ export function renderAction(
|
|
|
416
420
|
throw new Error(`action "${name}" is not declared in this config`);
|
|
417
421
|
}
|
|
418
422
|
const store = runtime.store;
|
|
419
|
-
if (!store) {
|
|
420
|
-
throw new Error(
|
|
421
|
-
`action "${name}" rendered without a VariableStore — registerDslConfig was not given one`,
|
|
422
|
-
);
|
|
423
|
-
}
|
|
424
423
|
const { display, boundValue } = selectDisplay(name, action, displays, store);
|
|
425
424
|
const sessionId = readVar(store, "session.id");
|
|
426
425
|
const { effect, active } = realize(
|
package/src/render/picker.ts
CHANGED
|
@@ -144,11 +144,6 @@ function renderPicker(
|
|
|
144
144
|
"an int action ({ set, int: true })",
|
|
145
145
|
);
|
|
146
146
|
const store = runtime.store;
|
|
147
|
-
if (!store) {
|
|
148
|
-
throw new Error(
|
|
149
|
-
`picker "${applyName}" rendered without a VariableStore — registerDslConfig was not given one`,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
147
|
const sessionId = readVar(store, "session.id");
|
|
153
148
|
const current = readVar(store, apply.stateVar);
|
|
154
149
|
const widths = apply.options.map(cellWidth);
|
|
@@ -580,6 +580,15 @@ export class SourceRegistry {
|
|
|
580
580
|
this.sessionState = sessionState;
|
|
581
581
|
}
|
|
582
582
|
|
|
583
|
+
// [LAW:one-source-of-truth] The registry IS the owner of its store — every
|
|
584
|
+
// variable it declares lives there, and the renderer reads back through it.
|
|
585
|
+
// Exposing it read-only lets a caller that already holds the registry obtain
|
|
586
|
+
// the one store without threading a second reference that could diverge (the
|
|
587
|
+
// action runtime reads session.id/current values from this exact store).
|
|
588
|
+
get variableStore(): VariableStore {
|
|
589
|
+
return this.store;
|
|
590
|
+
}
|
|
591
|
+
|
|
583
592
|
// ─── Synchronous source kinds ─────────────────────────────────────────────
|
|
584
593
|
|
|
585
594
|
// literal: type inferred from value; box written once at declaration and never again.
|