@promptctl/cc-candybar 1.3.0 → 1.5.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.3.0",
3
+ "version": "1.5.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.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"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.5.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.5.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.5.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.5.0"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -25,6 +25,13 @@
25
25
  },
26
26
  "palette": {
27
27
  "type": "string"
28
+ },
29
+ "style": {
30
+ "enum": [
31
+ "powerline",
32
+ "capsule",
33
+ "plain"
34
+ ]
28
35
  }
29
36
  },
30
37
  "additionalProperties": false
@@ -386,6 +386,31 @@ export const DEFAULT_DSL_CONFIG = {
386
386
  "burn.eta.warnMinutes": { kind: "literal", value: 60 },
387
387
  "burn.eta.errorMinutes": { kind: "literal", value: 30 },
388
388
 
389
+ // Token throughput for the active turn — daemon-derived tok/s on three lanes
390
+ // (render-payload.ts: successive-render delta over the SessionUsageStore).
391
+ // Same absence idiom as burn: -1 is the structurally-impossible default the
392
+ // `formatSpeed` helper reads as "—" [LAW:no-silent-failure] (0 tok/s is a
393
+ // real reading, so it cannot double as the absence marker). Each lane is
394
+ // independently absent — `input` reads "—" mid-stream while `output` flows.
395
+ "speed.input": {
396
+ kind: "input",
397
+ path: "speed.input",
398
+ type: "number",
399
+ default: -1,
400
+ },
401
+ "speed.output": {
402
+ kind: "input",
403
+ path: "speed.output",
404
+ type: "number",
405
+ default: -1,
406
+ },
407
+ "speed.total": {
408
+ kind: "input",
409
+ path: "speed.total",
410
+ type: "number",
411
+ default: -1,
412
+ },
413
+
389
414
  // Context — daemon fetches via ContextProvider; contextLeftPercentage.
390
415
  "context.totalTokens": {
391
416
  kind: "input",
@@ -437,6 +462,18 @@ export const DEFAULT_DSL_CONFIG = {
437
462
  type: "number",
438
463
  default: 0,
439
464
  },
465
+
466
+ // ── Style picker state (the live powerline-shape switcher) ───────────────
467
+ // [LAW:one-source-of-truth] `activeStyle` reads the SAME "style" SessionState
468
+ // key the daemon resolves into the strip joiner per render (see
469
+ // effectiveStripStyle wiring in src/daemon/server.ts) — the picker's write
470
+ // and the render's read are one value. Empty default ⇒ the daemon's
471
+ // "powerline" floor is in effect and styleControl shows "(default)".
472
+ activeStyle: { kind: "state", key: "style", default: "" },
473
+ // The style menu's page cursor: −1 closed / 0..N open, mirroring how a theme
474
+ // picker's page key gates its reveal row. The stylePage action declares the
475
+ // int gate; this var reads it back for the reveal `when`.
476
+ stylePage: { kind: "state", key: "style-page", default: "-1" },
440
477
  },
441
478
 
442
479
  // ─── Segments ──────────────────────────────────────────────────────────────
@@ -569,6 +606,23 @@ export const DEFAULT_DSL_CONFIG = {
569
606
  fg: etaHeatFg(".block.etaMinutes", ".burn.eta.warnMinutes"),
570
607
  when: "{{ or (gt .block.resetsAt 0) (gt .weekly.resetsAt 0) }}",
571
608
  },
609
+ // Token throughput for the active turn — output / input / total tok/s, each a
610
+ // successive-render delta computed daemon-side (render-payload.ts); the
611
+ // template only formats. Declared-but-opt-in (NOT in the default root, like
612
+ // block/weekly/burnrate): a user adds `speed` to their layout. Each lane reads
613
+ // "—" when idle/between turns ([LAW:no-silent-failure] — never a stale or
614
+ // divide-by-zero number). Visible once the session has done any work (stable,
615
+ // no layout flicker); `output` is the live generation rate, `input` spikes at
616
+ // turn start, `total` is their sum.
617
+ speed: {
618
+ template:
619
+ ' ⇅ out {{ template "formatSpeed" .speed.output }} · ' +
620
+ 'in {{ template "formatSpeed" .speed.input }} · ' +
621
+ 'tot {{ template "formatSpeed" .speed.total }} ',
622
+ bg: "panel",
623
+ fg: "foreground",
624
+ when: "{{ gt .session.tokens 0 }}",
625
+ },
572
626
  // Prompt-cache warmth countdown. minutesUntilReset clamps a past expiry
573
627
  // to 0, so an expired cache renders "cold" (and reads red via the ≤8
574
628
  // arm) rather than a negative number. [LAW:dataflow-not-control-flow]
@@ -623,25 +677,60 @@ export const DEFAULT_DSL_CONFIG = {
623
677
  " .metrics.sessionDuration .metrics.messageCount" +
624
678
  " .metrics.linesAdded .metrics.linesRemoved }}",
625
679
  },
680
+ // Style control — the powerline-shape switcher's trigger. [LAW:locality-or-
681
+ // seam] The ✦ glyph + current-style label is the REPRESENTATION; the
682
+ // `openStyleMenu` action is the BEHAVIOR; the name is the seam. Shows the
683
+ // active shape (or "(default)" when unset) and a ▸ that opens the picker
684
+ // row. [LAW:dataflow-not-control-flow] No display state from the provider —
685
+ // the label is the one "style" value the click writes and the render reads.
686
+ styleControl: {
687
+ template:
688
+ "✦ {{ if .activeStyle }}{{ .activeStyle }}{{ else }}(default){{ end }} " +
689
+ '{{ action "openStyleMenu" "▸" }}',
690
+ bg: "surface",
691
+ fg: "foreground",
692
+ },
693
+ // The expanded style picker: one full-width page (paged=false) over the
694
+ // `applyStyle` option domain — the 3 powerline shapes. closeOnPick folds a
695
+ // page-reset into the apply, so a pick reshapes the bar and closes the row
696
+ // in one click. The active shape is marked by the picker helper.
697
+ stylePicker: {
698
+ template: '{{ picker "applyStyle" "stylePage" true false }}',
699
+ bg: "surface",
700
+ fg: "foreground",
701
+ },
626
702
  },
627
703
 
628
- // Default layout — a single horizontal row of segment refs.
629
- // A-grammar equivalent: { h: ["directory","git","model","session","today","context","toolbar"] }
630
- // [LAW:one-source-of-truth] The bundled default now authors the same terse
631
- // surface every user config lowers to, so the default is the reference spelling.
632
- // Adding rows = wrapping in { kind:"container", direction:"vertical", children:[...] };
633
- // every segment is already declared above.
704
+ // Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
705
+ // requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
706
+ // the loader's authoring surface for user JSON, not this typed literal).
707
+ // [LAW:dataflow-not-control-flow] Two rows: an always-on control row, and a
708
+ // picker reveal row that EXISTS only while the style menu cursor is ≥ 0 — the
709
+ // row's presence is a value test on stylePage, not a branch in render code.
710
+ // The picker itself draws the ✕/←/→ affordances from the page + term width.
634
711
  root: {
635
712
  kind: "container",
636
- direction: "horizontal",
713
+ direction: "vertical",
637
714
  children: [
638
- { kind: "segment", name: "directory" },
639
- { kind: "segment", name: "git" },
640
- { kind: "segment", name: "model" },
641
- { kind: "segment", name: "session" },
642
- { kind: "segment", name: "today" },
643
- { kind: "segment", name: "context" },
644
- { kind: "segment", name: "toolbar" },
715
+ {
716
+ kind: "container",
717
+ direction: "horizontal",
718
+ children: [
719
+ { kind: "segment", name: "directory" },
720
+ { kind: "segment", name: "git" },
721
+ { kind: "segment", name: "model" },
722
+ { kind: "segment", name: "session" },
723
+ { kind: "segment", name: "today" },
724
+ { kind: "segment", name: "context" },
725
+ { kind: "segment", name: "toolbar" },
726
+ { kind: "segment", name: "styleControl" },
727
+ ],
728
+ },
729
+ {
730
+ kind: "segment",
731
+ name: "stylePicker",
732
+ when: "{{ ge (int .stylePage) 0 }}",
733
+ },
645
734
  ],
646
735
  },
647
736
 
@@ -664,6 +753,17 @@ export const DEFAULT_DSL_CONFIG = {
664
753
  copyDir: { copy: "{{ .current_dir }}" },
665
754
  openProject: { open: "{{ .project_dir }}" },
666
755
  openTranscript: { open: "{{ .transcript_path }}" },
756
+
757
+ // [LAW:locality-or-seam] The style picker's behaviors, decoupled by NAME
758
+ // from styleControl/stylePicker above. Three declarations, all gated by
759
+ // derivation (deriveActionValidators): openStyleMenu/stylePage write the
760
+ // page cursor (a literal page-open subsumed by the int gate); applyStyle
761
+ // writes the chosen shape, gated to the STRIP_STYLES allow-list because its
762
+ // value source is `from: "styles"`. The rendered click and the wire gate
763
+ // share that one source — a template cannot smuggle an un-gated style write.
764
+ openStyleMenu: { set: "style-page", to: "0" },
765
+ applyStyle: { set: "style", from: "styles" },
766
+ stylePage: { set: "style-page", int: true },
667
767
  },
668
768
 
669
769
  // [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
@@ -701,6 +801,11 @@ export const DEFAULT_DSL_CONFIG = {
701
801
  // daemon could not project (-1 sentinel). Reuses the long-remaining cascade.
702
802
  formatEta:
703
803
  '{{ if lt . 0 }}—{{ else }}{{ template "formatLongTimeRemaining" . }}{{ end }}',
804
+ // Token throughput: "N/s" (K/M-scaled, rounded) when measured (>= 0), "—"
805
+ // when the daemon had no projectable sample (-1). Branches on the VALUE, like
806
+ // formatRate; reuses formatTokenCount so the K/M scale policy has one home.
807
+ formatSpeed:
808
+ '{{ if lt . 0 }}—{{ else }}{{ template "formatTokenCount" (round .) }}/s{{ end }}',
704
809
  // Breakdown over a dict {input, output, cacheCreation, cacheRead}; each present
705
810
  // part is formatted by the shared formatTokenCount and joined with " + ". A
706
811
  // `$first` flag (reassigned across if-frames) inserts the separator before all
@@ -14,6 +14,7 @@
14
14
  // references it here. The dependency is one-way (this file → action.ts), never
15
15
  // the reverse, so that shape can be lifted out without a cycle.
16
16
  import type { ActionDecl } from "./action.js";
17
+ import type { StripStyle } from "../themes/policy.js";
17
18
 
18
19
  // [LAW:types-are-the-program] Three stages, three names.
19
20
  //
@@ -180,6 +181,14 @@ export interface Globals {
180
181
  // `sessionState.theme ?? globals.palette ?? default`, and a per-segment
181
182
  // `palette` is an explicit override that ignores the session theme.
182
183
  readonly palette?: string;
184
+
185
+ // [LAW:one-type-per-behavior] The config default for the powerline cap/
186
+ // separator SHAPE — the exact twin of `palette` one dimension over: the
187
+ // daemon resolves the live strip style per render as
188
+ // `sessionState.style ?? globals.style ?? "powerline"` (effectiveStripStyle),
189
+ // so a style click reshapes the bar live and a config can set the default
190
+ // shape without an edit-per-session.
191
+ readonly style?: StripStyle;
183
192
  }
184
193
 
185
194
  // [LAW:one-type-per-behavior] One discriminated union covers every source
@@ -4,7 +4,9 @@
4
4
  // add a key to GLOBALS_SCHEMA and Globals; the engine does the rest.
5
5
 
6
6
  import { type Globals } from "../dsl-types.js";
7
+ import { STRIP_STYLES } from "../../themes/policy.js";
7
8
  import {
9
+ optionalEnumSpec,
8
10
  optionalStringSpec,
9
11
  paletteSpec,
10
12
  record,
@@ -23,6 +25,10 @@ const GLOBALS_SCHEMA: RecordSchema<Globals> = {
23
25
  default_separator: optionalStringSpec(),
24
26
  default_truncate_marker: optionalStringSpec(),
25
27
  palette: paletteSpec(),
28
+ // [LAW:types-are-the-program] The strip style is a CLOSED enum (the powerline
29
+ // shapes the joiner can render), unlike the open-ended palette NAME — so it
30
+ // validates by membership and emits a JSON-Schema `enum`.
31
+ style: optionalEnumSpec(STRIP_STYLES),
26
32
  },
27
33
  };
28
34
 
@@ -47,6 +47,42 @@ export interface TodayInfo {
47
47
  date: string;
48
48
  }
49
49
 
50
+ // [LAW:one-source-of-truth] One observation of the active session's cumulative
51
+ // token counts at a single instant. tok/s is the delta between two of these —
52
+ // the prior sample lives in this store (the single owner of per-session token
53
+ // totals), never in a parallel counter. `input` folds the cache lanes into the
54
+ // prompt-side total so `total === input + output`. `atMs` is the render's clock
55
+ // instant (the daemon's single-enforcer clock), so a frozen test clock makes
56
+ // the rate deterministic.
57
+ export interface SpeedSample {
58
+ readonly input: number;
59
+ readonly output: number;
60
+ readonly total: number;
61
+ readonly atMs: number;
62
+ }
63
+
64
+ // The prior observation (absent on the very first render of a session) and the
65
+ // one just taken. The pure rate projection lives at the render-payload boundary;
66
+ // this store only remembers and reports the pair.
67
+ export interface SpeedObservation {
68
+ readonly prev?: SpeedSample;
69
+ readonly cur: SpeedSample;
70
+ }
71
+
72
+ function speedSampleOf(
73
+ breakdown: TokenBreakdown | null,
74
+ atMs: number,
75
+ ): SpeedSample {
76
+ // Prompt-side = raw input plus both cache lanes (all tokens fed to the model);
77
+ // output = generated. total = the same sum the store's `tokens` projection
78
+ // uses, so `total === input + output`. [LAW:one-source-of-truth]
79
+ const input = breakdown
80
+ ? breakdown.input + breakdown.cacheCreation + breakdown.cacheRead
81
+ : 0;
82
+ const output = breakdown ? breakdown.output : 0;
83
+ return { input, output, total: input + output, atMs };
84
+ }
85
+
50
86
  // Per-(session, day) scalar contribution — the only granularity the today fold
51
87
  // needs. Raw entries are discarded after bucketing, so per-session retained
52
88
  // memory is O(retained-days), not O(entries).
@@ -166,6 +202,17 @@ export class SessionUsageStore {
166
202
  // first seed completes every later read awaits an already-settled promise —
167
203
  // zero rescan. A rejected seed is dropped so the next read retries.
168
204
  private readonly seeded = new Map<string, Promise<void>>();
205
+ // [LAW:one-source-of-truth] The prior tok/s observation per session. tok/s is
206
+ // a derivative of the SAME token totals the records map already owns; the
207
+ // baseline (prev counts + time) lives HERE, not in a parallel counter. One
208
+ // call to observeSpeed advances it; the pure delta math is render-payload's.
209
+ private readonly speedSamples = new Map<string, SpeedSample>();
210
+ // [LAW:no-ambient-temporal-coupling] Explicit owner of observe/commit ordering
211
+ // for the speed sample. Concurrent renders observing the SAME transcript state
212
+ // (key = `${sessionId}:${mtime}`) share ONE observation and commit the baseline
213
+ // exactly once — so they return the same prev+cur and render identical,
214
+ // deterministic throughput instead of the second clobbering the first.
215
+ private readonly speedFlight = new SingleFlight();
169
216
  private readonly maxEntries: number;
170
217
  private readonly staleAgeMs: number;
171
218
  private hits = 0;
@@ -284,6 +331,37 @@ export class SessionUsageStore {
284
331
  });
285
332
  }
286
333
 
334
+ // Take one tok/s observation of the active session: ingest its current
335
+ // cumulative counts, return the prior sample alongside, and record this one as
336
+ // the new baseline. [LAW:no-silent-failure] A failed transcript parse flows out
337
+ // as `failed` (the boundary logs it and the segment reads "—"); an unknown
338
+ // session yields a zero-count sample, so a first-ever render establishes a
339
+ // baseline without fabricating a rate. `nowMs` is the caller's single-enforcer
340
+ // clock instant — the store never reads the clock for tok/s timing itself.
341
+ async observeSpeed(
342
+ sessionId: string,
343
+ transcriptPath: string | undefined,
344
+ nowMs: number,
345
+ ): Promise<Outcome<SpeedObservation>> {
346
+ // [LAW:no-ambient-temporal-coupling] Key the observation by the same
347
+ // (session, mtime) tuple ingest uses, so concurrent renders at one transcript
348
+ // state coalesce onto a single observe-and-commit — the read of `prev` and
349
+ // the write of `cur` happen exactly once for that state, with the flight as
350
+ // the sole owner of ordering. A distinct mtime is a genuinely new sample and
351
+ // gets its own key.
352
+ const mtime = statMtimeMs(transcriptPath);
353
+ return this.speedFlight.run(`${sessionId}:${mtime}`, async () => {
354
+ const record = await this.ingest(sessionId, transcriptPath, mtime);
355
+ if (record.kind === "failed") return record;
356
+ const breakdown =
357
+ record.kind === "ok" ? record.value.sessionInfo.tokenBreakdown : null;
358
+ const cur = speedSampleOf(breakdown, nowMs);
359
+ const prev = this.speedSamples.get(sessionId);
360
+ this.speedSamples.set(sessionId, cur);
361
+ return ok({ ...(prev !== undefined && { prev }), cur });
362
+ });
363
+ }
364
+
287
365
  // mtime-gated, coalesced re-parse of ONE session. `ok` is its record,
288
366
  // `absent` is an unknown empty session (or no sessionId), `failed` is a
289
367
  // transcript that exists but couldn't be parsed — NOT cached (only ok
@@ -416,6 +494,7 @@ export class SessionUsageStore {
416
494
  for (const [sid, record] of this.entries) {
417
495
  if (now - record.lastSeenAt > this.staleAgeMs) {
418
496
  this.entries.delete(sid);
497
+ this.speedSamples.delete(sid);
419
498
  dropped++;
420
499
  }
421
500
  }
@@ -431,6 +510,7 @@ export class SessionUsageStore {
431
510
  const oldest = this.entries.keys().next().value;
432
511
  if (oldest === undefined) break;
433
512
  this.entries.delete(oldest);
513
+ this.speedSamples.delete(oldest);
434
514
  dlog("info", `usageStore evict ${oldest}`);
435
515
  }
436
516
  }
@@ -442,5 +522,6 @@ export class SessionUsageStore {
442
522
  }
443
523
  this.entries.clear();
444
524
  this.seeded.clear();
525
+ this.speedSamples.clear();
445
526
  }
446
527
  }
@@ -25,7 +25,10 @@ import type { GitInfo, GitInfoOptions } from "../segments/git.js";
25
25
  import { ABSENT, failed, type Outcome } from "../utils/outcome.js";
26
26
  import { cacheExpiresAt } from "../segments/cache.js";
27
27
  import type { DaemonLogger } from "./log.js";
28
- import type { SessionUsageStore } from "./cache/session-usage-store.js";
28
+ import type {
29
+ SessionUsageStore,
30
+ SpeedObservation,
31
+ } from "./cache/session-usage-store.js";
29
32
  import type { ContextProvider } from "../segments/context.js";
30
33
  import type { MetricsProvider } from "../segments/metrics.js";
31
34
  import type { TmuxService } from "../segments/tmux.js";
@@ -65,6 +68,7 @@ export interface RenderPayload extends ClaudeHookData {
65
68
  readonly session?: SessionPayload;
66
69
  readonly today?: TodayPayload;
67
70
  readonly burn?: BurnPayload;
71
+ readonly speed?: SpeedPayload;
68
72
  readonly block?: BlockPayload;
69
73
  readonly weekly?: WeeklyPayload;
70
74
  readonly cache?: CachePayload;
@@ -115,6 +119,19 @@ export interface BurnPayload {
115
119
  readonly costPerHour?: number;
116
120
  }
117
121
 
122
+ // [LAW:one-type-per-behavior] Token throughput for the active turn — tokens per
123
+ // second on each of three lanes (prompt-side input, generated output, their
124
+ // total). Each lane is INDEPENDENTLY optional: during streaming `output` moves
125
+ // while `input` (fixed at turn start) is idle, so an absent `input` rate beside a
126
+ // live `output` rate is the honest shape, not a zero. Absence (no baseline yet,
127
+ // idle between turns, or a too-stale prior sample) travels as a missing field to
128
+ // the -1 default, which the `formatSpeed` helper reads as "—". [LAW:no-silent-failure]
129
+ export interface SpeedPayload {
130
+ readonly input?: number;
131
+ readonly output?: number;
132
+ readonly total?: number;
133
+ }
134
+
118
135
  export interface BlockPayload {
119
136
  readonly nativeUtilization: number;
120
137
  readonly resetsAt: number;
@@ -200,6 +217,15 @@ const MIN_PROJECTABLE_ELAPSED_MS = 5 * 60 * 1000;
200
217
  // $/hr is dominated by a single turn rather than a sustained burn.
201
218
  const MIN_BURN_SECONDS = 60;
202
219
 
220
+ // tok/s is a delta between two successive render observations. The wall-time
221
+ // between them must clear a tiny floor (the clock has to have advanced — below
222
+ // it the rate is divide-by-near-zero noise) and stay under a ceiling: a gap
223
+ // wider than this means the prior sample predates an idle stretch, so the rate
224
+ // would be diluted by dead time. Both bounds → no reading (re-baseline silently
225
+ // on the next render) rather than a misleading number. [LAW:no-silent-failure]
226
+ const MIN_SPEED_SAMPLE_MS = 50;
227
+ const MAX_SPEED_SAMPLE_MS = 10 * 1000;
228
+
203
229
  /**
204
230
  * Linearly extrapolate a rate-limit window's utilization to its 100% cap.
205
231
  * The window started `windowMs` before `resetsAtSec`; elapsed time and the
@@ -236,6 +262,64 @@ export function projectCostPerHour(
236
262
  return (cost * 3600) / durationSeconds;
237
263
  }
238
264
 
265
+ /**
266
+ * Instantaneous tokens-per-second between two successive render observations of
267
+ * one cumulative token count. Returns undefined when the sample window is too
268
+ * small or too large to be honest (see the floor/ceiling constants) or when the
269
+ * count did not advance (idle / between turns — a true 0 over a real window is
270
+ * reported as 0, but a flat count carries no throughput to report). A real
271
+ * positive rate is always >= 0, so callers use -1 as the absence default — 0
272
+ * tok/s never doubles as the "no reading" marker. [LAW:no-silent-failure]
273
+ */
274
+ export function projectTokensPerSecond(
275
+ prevTokens: number,
276
+ prevMs: number,
277
+ curTokens: number,
278
+ nowMs: number,
279
+ ): number | undefined {
280
+ const deltaMs = nowMs - prevMs;
281
+ if (deltaMs < MIN_SPEED_SAMPLE_MS || deltaMs > MAX_SPEED_SAMPLE_MS)
282
+ return undefined;
283
+ const deltaTokens = curTokens - prevTokens;
284
+ if (deltaTokens <= 0) return undefined;
285
+ return (deltaTokens * 1000) / deltaMs;
286
+ }
287
+
288
+ // [LAW:effects-at-boundaries] Pure fold of one speed observation into the
289
+ // payload's three rate lanes. No baseline (first render of a session) or every
290
+ // lane un-projectable → undefined (the whole `speed` key is dropped); otherwise
291
+ // each lane that projects contributes its rate, each that doesn't is a missing
292
+ // field → the -1 default → "—".
293
+ function projectSpeed(obs: SpeedObservation): SpeedPayload | undefined {
294
+ const { prev, cur } = obs;
295
+ if (prev === undefined) return undefined;
296
+ const input = projectTokensPerSecond(
297
+ prev.input,
298
+ prev.atMs,
299
+ cur.input,
300
+ cur.atMs,
301
+ );
302
+ const output = projectTokensPerSecond(
303
+ prev.output,
304
+ prev.atMs,
305
+ cur.output,
306
+ cur.atMs,
307
+ );
308
+ const total = projectTokensPerSecond(
309
+ prev.total,
310
+ prev.atMs,
311
+ cur.total,
312
+ cur.atMs,
313
+ );
314
+ if (input === undefined && output === undefined && total === undefined)
315
+ return undefined;
316
+ return {
317
+ ...(input !== undefined && { input }),
318
+ ...(output !== undefined && { output }),
319
+ ...(total !== undefined && { total }),
320
+ };
321
+ }
322
+
239
323
  // ─── Builder ─────────────────────────────────────────────────────────────────
240
324
 
241
325
  // ─── Config-driven provider gating ───────────────────────────────────────────
@@ -428,6 +512,11 @@ export async function buildRenderPayload(
428
512
  const wants = (prefix: string): boolean =>
429
513
  anyPathStartsWith(neededInputPaths, prefix);
430
514
 
515
+ // [LAW:single-enforcer] One clock read feeds every projection this render —
516
+ // the ETA extrapolations below AND the tok/s sample window in the speed lane,
517
+ // so an ETA, a reset countdown, and a throughput figure all agree on "now".
518
+ const nowMs = (deps.clock ?? (() => new Date()))().getTime();
519
+
431
520
  // [LAW:dataflow-not-control-flow][LAW:one-type-per-behavior] Every provider
432
521
  // lane is ONE shape: "needed → call provider (whose contract is to never
433
522
  // reject — the catch makes the lane total against bugs, mapping a throw
@@ -443,40 +532,57 @@ export async function buildRenderPayload(
443
532
  ? run().catch((e: unknown) => failed(`${name}: ${String(e)}`))
444
533
  : Promise.resolve(ABSENT);
445
534
 
446
- const [gitOutcome, usage, today, context, metrics, tmuxSession, cacheExpiry] =
447
- await Promise.all([
448
- lane("git", wants("git"), () =>
449
- deps.gitProvider.getGitInfo(
450
- cwd ?? hookData.workspace?.current_dir,
451
- gitOptionsFromClosure(neededInputPaths),
452
- hookData.workspace?.project_dir,
453
- ),
454
- ),
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),
535
+ const [
536
+ gitOutcome,
537
+ usage,
538
+ today,
539
+ context,
540
+ metrics,
541
+ tmuxSession,
542
+ cacheExpiry,
543
+ speed,
544
+ ] = await Promise.all([
545
+ lane("git", wants("git"), () =>
546
+ deps.gitProvider.getGitInfo(
547
+ cwd ?? hookData.workspace?.current_dir,
548
+ gitOptionsFromClosure(neededInputPaths),
549
+ hookData.workspace?.project_dir,
462
550
  ),
463
- lane("today", wants("today"), () =>
464
- deps.usageStore.getTodayInfo(hookData),
465
- ),
466
- lane("context", wants("context"), () =>
467
- deps.contextProvider.getContextInfo(hookData),
468
- ),
469
- lane("metrics", wants("metrics") || wants("burn"), () =>
470
- deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
471
- ),
472
- lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
473
- // Prompt-cache expiry: a bounded tail-read through the gated transcript-fs
474
- // seam, so it runs alongside the other providers and stays in the shared
475
- // in-flight budget rather than blocking the event loop on sync fs.
476
- lane("cache", wants("cache"), () =>
477
- cacheExpiresAt(hookData.transcript_path),
551
+ ),
552
+ // [LAW:dataflow-not-control-flow] The burn segment reads `burn.costPerHour`,
553
+ // a derivative of session cost and metrics duration — so wanting `burn`
554
+ // pulls in exactly the two lanes it is folded from.
555
+ lane(
556
+ "session",
557
+ wants("session.cost") || wants("session.tokens") || wants("burn"),
558
+ () => deps.usageStore.getUsageInfo(hookData.session_id, hookData),
559
+ ),
560
+ lane("today", wants("today"), () => deps.usageStore.getTodayInfo(hookData)),
561
+ lane("context", wants("context"), () =>
562
+ deps.contextProvider.getContextInfo(hookData),
563
+ ),
564
+ lane("metrics", wants("metrics") || wants("burn"), () =>
565
+ deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
566
+ ),
567
+ lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
568
+ // Prompt-cache expiry: a bounded tail-read through the gated transcript-fs
569
+ // seam, so it runs alongside the other providers and stays in the shared
570
+ // in-flight budget rather than blocking the event loop on sync fs.
571
+ lane("cache", wants("cache"), () =>
572
+ cacheExpiresAt(hookData.transcript_path),
573
+ ),
574
+ // [LAW:one-source-of-truth] tok/s folds from the SAME store the session
575
+ // lane reads — observeSpeed both reports the prior sample and records this
576
+ // render's, so it must run every render the speed segment is laid out (the
577
+ // first establishes the baseline that the second projects from).
578
+ lane("speed", wants("speed"), () =>
579
+ deps.usageStore.observeSpeed(
580
+ hookData.session_id,
581
+ hookData.transcript_path,
582
+ nowMs,
478
583
  ),
479
- ]);
584
+ ),
585
+ ]);
480
586
  // [LAW:effects-at-boundaries] The projections are pure folds returning data
481
587
  // (payload fragment + failure descriptions); the log effect happens once,
482
588
  // here, at the edge. `take` is the total fold for the single-value lanes:
@@ -505,8 +611,6 @@ export async function buildRenderPayload(
505
611
  // the formatter func — a duplicate code path was retired.)
506
612
  const fiveHour = hookData.rate_limits?.five_hour;
507
613
  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
614
  const blockEta = fiveHour
511
615
  ? projectEtaMinutes(
512
616
  fiveHour.used_percentage,
@@ -532,6 +636,13 @@ export async function buildRenderPayload(
532
636
  wants("burn") && burnCost != null && burnDuration != null
533
637
  ? projectCostPerHour(burnCost, burnDuration)
534
638
  : undefined;
639
+ // [LAW:effects-at-boundaries] The store reported the prev+cur samples (an
640
+ // effect: it read state and advanced the baseline); the rate is a pure fold of
641
+ // that data here at the edge. Absent observation (lane skipped/failed) or no
642
+ // projectable lane → no `speed` key → every lane reads its -1 default.
643
+ const speedObs = take(speed);
644
+ const speedPayload =
645
+ speedObs !== undefined ? projectSpeed(speedObs) : undefined;
535
646
 
536
647
  // [LAW:one-source-of-truth] The theme variable surfaces the session's
537
648
  // resolved theme so the toolbar/tray DSL templates can encode it into
@@ -597,6 +708,7 @@ export async function buildRenderPayload(
597
708
  ...(sessionPayload !== undefined && { session: sessionPayload }),
598
709
  ...(todayPayload !== undefined && { today: todayPayload }),
599
710
  ...(costPerHour !== undefined && { burn: { costPerHour } }),
711
+ ...(speedPayload !== undefined && { speed: speedPayload }),
600
712
  ...(wants("block") &&
601
713
  fiveHour !== undefined && {
602
714
  block: {