@promptctl/cc-candybar 1.4.0 → 1.6.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.4.0",
3
+ "version": "1.6.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.4.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.4.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.4.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.4.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.6.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.6.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.6.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.6.0"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -280,6 +280,21 @@ export const DEFAULT_DSL_CONFIG = {
280
280
  default: 0,
281
281
  },
282
282
 
283
+ // Forge PR/MR — the daemon's git provider resolves the branch's open PR via
284
+ // gh/glab and projects it here. Declaring any of these turns on the network
285
+ // lookup. [LAW:no-silent-failure] prError is non-empty ONLY when the forge
286
+ // was asked but couldn't answer (auth/network) — distinct from "no PR"
287
+ // (every field empty). prNumber 0 (default) ⇒ no open PR.
288
+ "git.prNumber": {
289
+ kind: "input",
290
+ path: "git.prNumber",
291
+ type: "number",
292
+ default: 0,
293
+ },
294
+ "git.prState": { kind: "input", path: "git.prState", default: "" },
295
+ "git.prUrl": { kind: "input", path: "git.prUrl", default: "" },
296
+ "git.prError": { kind: "input", path: "git.prError", default: "" },
297
+
283
298
  // Prompt-cache expiry — epoch seconds, projected by the cache provider.
284
299
  // Same unit/shape as block/weekly resetsAt so the cacheTimer segment
285
300
  // composes `minutesUntilReset` identically. 0 (default) ⇒ no cache
@@ -386,6 +401,31 @@ export const DEFAULT_DSL_CONFIG = {
386
401
  "burn.eta.warnMinutes": { kind: "literal", value: 60 },
387
402
  "burn.eta.errorMinutes": { kind: "literal", value: 30 },
388
403
 
404
+ // Token throughput for the active turn — daemon-derived tok/s on three lanes
405
+ // (render-payload.ts: successive-render delta over the SessionUsageStore).
406
+ // Same absence idiom as burn: -1 is the structurally-impossible default the
407
+ // `formatSpeed` helper reads as "—" [LAW:no-silent-failure] (0 tok/s is a
408
+ // real reading, so it cannot double as the absence marker). Each lane is
409
+ // independently absent — `input` reads "—" mid-stream while `output` flows.
410
+ "speed.input": {
411
+ kind: "input",
412
+ path: "speed.input",
413
+ type: "number",
414
+ default: -1,
415
+ },
416
+ "speed.output": {
417
+ kind: "input",
418
+ path: "speed.output",
419
+ type: "number",
420
+ default: -1,
421
+ },
422
+ "speed.total": {
423
+ kind: "input",
424
+ path: "speed.total",
425
+ type: "number",
426
+ default: -1,
427
+ },
428
+
389
429
  // Context — daemon fetches via ContextProvider; contextLeftPercentage.
390
430
  "context.totalTokens": {
391
431
  kind: "input",
@@ -517,6 +557,24 @@ export const DEFAULT_DSL_CONFIG = {
517
557
  fg: "foreground",
518
558
  when: '{{ ne .git.branch "" }}',
519
559
  },
560
+ // Git PR/MR — the branch's open pull/merge request as a clickable link.
561
+ // OPT-IN: declared but NOT in the default root (it adds a network gh/glab
562
+ // call). Add "gitPr" to a container's children to enable it. The `{{ link
563
+ // url text }}` emits ONE OSC-8 region carrying the https PR url, so the
564
+ // CLICK is handled by the terminal/OS (opens the browser) — no daemon verb.
565
+ // [LAW:no-silent-failure] Three render states from the data: an open PR
566
+ // (prUrl set) renders the link; a lookup FAILURE (prError set, prUrl empty)
567
+ // renders a distinct ⚠ marker so an outage is not mistaken for "no PR";
568
+ // no PR (both empty) leaves the `when` gate false and the segment absent.
569
+ gitPr: {
570
+ template:
571
+ '{{ if ne .git.prUrl "" }}' +
572
+ '{{ link .git.prUrl (printf " ⇆ #%v " .git.prNumber) }}' +
573
+ "{{ else }} ⚠ PR {{ end }}",
574
+ bg: "surface-active",
575
+ fg: "foreground",
576
+ when: '{{ or (ne .git.prUrl "") (ne .git.prError "") }}',
577
+ },
520
578
  // Quick-action tray — copy the session id / cwd, open the project dir /
521
579
  // transcript in the editor. [LAW:locality-or-seam] The glyph is the
522
580
  // REPRESENTATION; the named action (below) is the BEHAVIOR; the action
@@ -581,6 +639,23 @@ export const DEFAULT_DSL_CONFIG = {
581
639
  fg: etaHeatFg(".block.etaMinutes", ".burn.eta.warnMinutes"),
582
640
  when: "{{ or (gt .block.resetsAt 0) (gt .weekly.resetsAt 0) }}",
583
641
  },
642
+ // Token throughput for the active turn — output / input / total tok/s, each a
643
+ // successive-render delta computed daemon-side (render-payload.ts); the
644
+ // template only formats. Declared-but-opt-in (NOT in the default root, like
645
+ // block/weekly/burnrate): a user adds `speed` to their layout. Each lane reads
646
+ // "—" when idle/between turns ([LAW:no-silent-failure] — never a stale or
647
+ // divide-by-zero number). Visible once the session has done any work (stable,
648
+ // no layout flicker); `output` is the live generation rate, `input` spikes at
649
+ // turn start, `total` is their sum.
650
+ speed: {
651
+ template:
652
+ ' ⇅ out {{ template "formatSpeed" .speed.output }} · ' +
653
+ 'in {{ template "formatSpeed" .speed.input }} · ' +
654
+ 'tot {{ template "formatSpeed" .speed.total }} ',
655
+ bg: "panel",
656
+ fg: "foreground",
657
+ when: "{{ gt .session.tokens 0 }}",
658
+ },
584
659
  // Prompt-cache warmth countdown. minutesUntilReset clamps a past expiry
585
660
  // to 0, so an expired cache renders "cold" (and reads red via the ≤8
586
661
  // arm) rather than a negative number. [LAW:dataflow-not-control-flow]
@@ -759,6 +834,11 @@ export const DEFAULT_DSL_CONFIG = {
759
834
  // daemon could not project (-1 sentinel). Reuses the long-remaining cascade.
760
835
  formatEta:
761
836
  '{{ if lt . 0 }}—{{ else }}{{ template "formatLongTimeRemaining" . }}{{ end }}',
837
+ // Token throughput: "N/s" (K/M-scaled, rounded) when measured (>= 0), "—"
838
+ // when the daemon had no projectable sample (-1). Branches on the VALUE, like
839
+ // formatRate; reuses formatTokenCount so the K/M scale policy has one home.
840
+ formatSpeed:
841
+ '{{ if lt . 0 }}—{{ else }}{{ template "formatTokenCount" (round .) }}/s{{ end }}',
762
842
  // Breakdown over a dict {input, output, cacheCreation, cacheRead}; each present
763
843
  // part is formatted by the shared formatTokenCount and joined with " + ". A
764
844
  // `$first` flag (reassigned across if-frames) inserts the separator before all
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { GitService, type GitInfo } from "../../segments/git";
4
- import { ok, type Outcome } from "../../utils/outcome";
3
+ import { GitService, type GitInfo, type PullRequest } from "../../segments/git";
4
+ import { ABSENT, ok, type Outcome } from "../../utils/outcome";
5
5
  import { debug } from "../../utils/logger";
6
6
  import { WatcherRegistry, type WatcherHandle } from "./watchers";
7
7
 
@@ -44,6 +44,17 @@ const DEFAULT_TTL_MS = 30_000;
44
44
  const DEFAULT_MAX_ENTRIES = 64;
45
45
  const SANITY_INTERVAL_MS = 5 * 60_000;
46
46
 
47
+ // [LAW:decomposition] The forge PR lookup is a network resource with a
48
+ // different lifecycle than local git state: it changes ~twice in a PR's life
49
+ // and the fs watchers on .git/HEAD|index say nothing about remote PR state. So
50
+ // it gets its OWN cache + TTL — independent of the 30s GitInfo TTL — so the
51
+ // per-render git fetch stays all-local and the gh/glab spawn happens at most
52
+ // once per window (or on branch switch, via the branch in the key). A `failed`
53
+ // lookup caches for a SHORTER window so a transient forge outage is retried
54
+ // soon, not pinned to the bar for the full ok-TTL.
55
+ const PR_TTL_OK_MS = 5 * 60_000;
56
+ const PR_TTL_FAIL_MS = 45_000;
57
+
47
58
  // Options applied to subscribe()'s internal getInfo() call. var-system's six
48
59
  // GitField values (branch, sha, dirty, ahead, behind, stash) all project from
49
60
  // these flags — keep them in lockstep with the projection in
@@ -60,6 +71,11 @@ interface MtimeSnapshot {
60
71
  index: number;
61
72
  }
62
73
 
74
+ interface PrCacheEntry {
75
+ pr: Outcome<PullRequest>;
76
+ computedAt: number;
77
+ }
78
+
63
79
  interface GitCacheEntry {
64
80
  info: GitInfo;
65
81
  computedAt: number;
@@ -147,6 +163,16 @@ export class GitDataProvider extends GitService {
147
163
  // here; subsequent concurrent callers await the same promise and resolve in
148
164
  // lockstep.
149
165
  private readonly fetchInFlight = new Map<string, Promise<Outcome<GitInfo>>>();
166
+ // [LAW:one-source-of-truth] The forge PR cache, keyed by `repoRoot|branch`
167
+ // (a branch switch is a new key, so its PR is fetched fresh; the old branch's
168
+ // entry ages out). Separate from `entries` because it carries its own TTL.
169
+ private readonly prCache = new Map<string, PrCacheEntry>();
170
+ // [LAW:single-enforcer] Coalesce concurrent PR misses on the same key — same
171
+ // role as fetchInFlight for the GitInfo path, but for the network forge call.
172
+ private readonly prFetchInFlight = new Map<
173
+ string,
174
+ Promise<Outcome<PullRequest>>
175
+ >();
150
176
  // [LAW:single-enforcer] Coalesce overlapping refreshes for the same repo.
151
177
  // `refreshing` holds the repoRoots whose refresh loop is currently
152
178
  // executing; `refreshAgain` is the trailing-edge flag: if a new
@@ -292,6 +318,15 @@ export class GitDataProvider extends GitService {
292
318
  if (outcome.kind !== "ok") return outcome;
293
319
  const info = outcome.value;
294
320
 
321
+ // [LAW:decomposition] The inner GitService never resolves the PR — the
322
+ // cache layer owns that lookup so it can give it an independent TTL. On the
323
+ // 30s GitInfo refetch the prCache is almost always warm, so attaching the
324
+ // PR here is a memory read; the gh/glab spawn only fires when the prCache
325
+ // entry has aged past its (outcome-dependent) TTL or the branch changed.
326
+ if (options.showPullRequest) {
327
+ info.pullRequest = await this.getPullRequestCached(repoRoot, info.branch);
328
+ }
329
+
295
330
  // Drop any prior entry for this exact key before re-inserting (so we
296
331
  // release its watcher refcount cleanly).
297
332
  this.dropEntry(key);
@@ -315,6 +350,63 @@ export class GitDataProvider extends GitService {
315
350
  return outcome;
316
351
  }
317
352
 
353
+ // [LAW:single-enforcer] The one read path for a branch's PR. Reads the remote
354
+ // (cheap local git) so the cache key reflects every input the PR value
355
+ // depends on — `repoRoot|branch|remote` — and a re-pointed origin is a fresh
356
+ // key, not a stale hit ([LAW:one-source-of-truth]). TTL is outcome-dependent
357
+ // (a `failed` retries sooner). Concurrent misses coalesce through
358
+ // prFetchInFlight. The forge dispatch delegates to inner.resolvePullRequest —
359
+ // this layer is cache + lifecycle only, never forge knowledge.
360
+ private async getPullRequestCached(
361
+ repoRoot: string,
362
+ branch: string,
363
+ ): Promise<Outcome<PullRequest>> {
364
+ // [LAW:no-silent-failure] A `failed` remote read (git couldn't run) must
365
+ // surface; only `absent` (no remote configured) means "no forge PR".
366
+ const remote = await this.inner.getRemoteOriginUrl(repoRoot);
367
+ if (remote.kind === "failed") return remote;
368
+ if (remote.kind === "absent") return ABSENT;
369
+ const remoteUrl = remote.value;
370
+
371
+ const key = `${repoRoot}|${branch}|${remoteUrl}`;
372
+ const now = Date.now();
373
+
374
+ const existing = this.prCache.get(key);
375
+ if (existing) {
376
+ const ttl = existing.pr.kind === "failed" ? PR_TTL_FAIL_MS : PR_TTL_OK_MS;
377
+ if (now - existing.computedAt < ttl) {
378
+ // LRU touch so the active branch's PR survives eviction.
379
+ this.prCache.delete(key);
380
+ this.prCache.set(key, existing);
381
+ return existing.pr;
382
+ }
383
+ }
384
+
385
+ const pending = this.prFetchInFlight.get(key);
386
+ if (pending) return pending;
387
+
388
+ const promise = this.inner
389
+ .resolvePullRequest(repoRoot, remoteUrl)
390
+ .then((pr) => {
391
+ this.prCache.set(key, { pr, computedAt: Date.now() });
392
+ this.evictPrIfNeeded();
393
+ return pr;
394
+ })
395
+ .finally(() => {
396
+ this.prFetchInFlight.delete(key);
397
+ });
398
+ this.prFetchInFlight.set(key, promise);
399
+ return promise;
400
+ }
401
+
402
+ private evictPrIfNeeded(): void {
403
+ while (this.prCache.size > this.maxEntries) {
404
+ const oldest = this.prCache.keys().next().value;
405
+ if (oldest === undefined) break;
406
+ this.prCache.delete(oldest);
407
+ }
408
+ }
409
+
318
410
  // [LAW:effects-at-boundaries] The subscribe surface's edge: fold the typed
319
411
  // outcome into the GitInfo|null the var-system callback contract expects.
320
412
  // `failed` is logged HERE — the one log site for this consumption path
@@ -548,6 +640,10 @@ export class GitDataProvider extends GitService {
548
640
  this.refreshing.clear();
549
641
  this.refreshAgain.clear();
550
642
  this.fetchInFlight.clear();
643
+ // In-flight PR fetches resolve naturally; clearing the maps means the next
644
+ // caller starts fresh (the prCache is rebuilt cold like every other cache).
645
+ this.prCache.clear();
646
+ this.prFetchInFlight.clear();
551
647
  if (this.ownsWatchers) this.watchers.closeAll();
552
648
  }
553
649
  }
@@ -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
  }