@promptctl/cc-candybar 1.17.5 → 1.18.1

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.17.5",
3
+ "version": "1.18.1",
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",
@@ -27,7 +27,7 @@
27
27
  "preloadtest": "pnpm build",
28
28
  "loadtest": "node --import tsx scripts/daemon-load-harness.ts",
29
29
  "preloadtest:gate": "pnpm build",
30
- "loadtest:gate": "node --import tsx scripts/daemon-load-harness.ts --sessions 25 --interval 300 --duration 20 --churn --transcript-lines 5000",
30
+ "loadtest:gate": "node --import tsx scripts/daemon-load-harness.ts --sessions 25 --interval 300 --duration 20 --churn --git-churn --transcript-lines 5000",
31
31
  "check:protocol": "node scripts/check-protocol.mjs",
32
32
  "gen:schema": "tsx scripts/gen-schema.ts",
33
33
  "check:schema": "tsx scripts/check-schema.ts",
@@ -95,10 +95,10 @@
95
95
  "mobx": "^6.15.0"
96
96
  },
97
97
  "optionalDependencies": {
98
- "@promptctl/cc-candybar-darwin-arm64": "1.17.5",
99
- "@promptctl/cc-candybar-darwin-x64": "1.17.5",
100
- "@promptctl/cc-candybar-linux-x64": "1.17.5",
101
- "@promptctl/cc-candybar-linux-arm64": "1.17.5"
98
+ "@promptctl/cc-candybar-darwin-arm64": "1.18.1",
99
+ "@promptctl/cc-candybar-darwin-x64": "1.18.1",
100
+ "@promptctl/cc-candybar-linux-x64": "1.18.1",
101
+ "@promptctl/cc-candybar-linux-arm64": "1.18.1"
102
102
  },
103
103
  "pnpm": {
104
104
  "supportedArchitectures": {
@@ -627,15 +627,18 @@ export const DEFAULT_DSL_CONFIG = {
627
627
  fg: "foreground",
628
628
  when: '{{ or (ne .git.prUrl "") (ne .git.prError "") }}',
629
629
  },
630
- // Quick-action tray — copy the session id / cwd, open the project dir /
631
- // transcript in the editor. [LAW:locality-or-seam] The glyph is the
632
- // REPRESENTATION; the named action (below) is the BEHAVIOR; the action
633
- // name is the seam between them. Re-glyph without touching behavior;
634
- // re-target without touching this template. Each `{{ action … }}` emits
635
- // one OSC-8 clickable region whose URL the wire codec owns end-to-end.
630
+ // Quick-action tray — the default bar's interactivity: copy the session id,
631
+ // open the project dir / transcript (this session's jsonl) in the editor.
632
+ // (copyDir — copy the cwd — stays declared as an action below for users who
633
+ // want a fourth glyph; it is simply not in the default tray.)
634
+ // [LAW:locality-or-seam] The glyph is the REPRESENTATION; the named action
635
+ // (below) is the BEHAVIOR; the action name is the seam between them. Re-glyph
636
+ // without touching behavior; re-target without touching this template. Each
637
+ // `{{ action … }}` emits one OSC-8 clickable region whose URL the wire codec
638
+ // owns end-to-end.
636
639
  toolbar: {
637
640
  template:
638
- '{{ action "copySession" "⎘ id" }} {{ action "copyDir" "⎘ cwd" }}' +
641
+ '{{ action "copySession" "⎘ id" }}' +
639
642
  ' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }}',
640
643
  bg: "surface",
641
644
  fg: "foreground",
@@ -808,11 +811,24 @@ export const DEFAULT_DSL_CONFIG = {
808
811
  // Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
809
812
  // requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
810
813
  // the loader's authoring surface for user JSON, not this typed literal).
811
- // [LAW:dataflow-not-control-flow] One always-on control row. The styleControl's
812
- // {{ menu }} disclosure drops its picker body onto the line directly below this
813
- // row when open openness is the value of the derived menus.* key, NOT a when-
814
- // gated reveal row. The picker draws the ✕/←/→ affordances from the page + term
815
- // width; the vertical container stacks the dropped body under row 0.
814
+ //
815
+ // Two rows stacked by the vertical container: an IDENTITY + ACTIONS row
816
+ // (where am I / what can I do here the directory, the verbose `gitaculous`
817
+ // line (repo, sha, working-tree, upstream, stash, time-since-commit), then the
818
+ // quick-action tray: copy session id, open project / transcript in the editor)
819
+ // over a STATUS row (what's happening now — model, context-window fill,
820
+ // prompt-cache warmth, and the 5h / 7d rate-limit quotas). The tray sits on
821
+ // the identity row because its actions are workspace-scoped (this session,
822
+ // this project), not usage metrics. Each row zips its segments through the
823
+ // powerline joiner; `\n` separates the rows.
824
+ //
825
+ // [LAW:dataflow-not-control-flow] Every status segment is when-gated on its
826
+ // own signal (no repo → the identity row is just the directory + tray; no
827
+ // active rate-limit window → block/weekly drop; no cache activity → cacheTimer
828
+ // drops). A row therefore only ever shows the segments that have real data —
829
+ // the layout is chosen by the data, not by branches — so the default never
830
+ // paints an empty or placeholder cell. The directory and the tray have no
831
+ // `when`, so row 1 always anchors the bar.
816
832
  root: {
817
833
  kind: "container",
818
834
  direction: "vertical",
@@ -822,13 +838,19 @@ export const DEFAULT_DSL_CONFIG = {
822
838
  direction: "horizontal",
823
839
  children: [
824
840
  { kind: "segment", name: "directory" },
825
- { kind: "segment", name: "git" },
841
+ { kind: "segment", name: "gitaculous" },
842
+ { kind: "segment", name: "toolbar" },
843
+ ],
844
+ },
845
+ {
846
+ kind: "container",
847
+ direction: "horizontal",
848
+ children: [
826
849
  { kind: "segment", name: "model" },
827
- { kind: "segment", name: "session" },
828
- { kind: "segment", name: "today" },
829
850
  { kind: "segment", name: "context" },
830
- { kind: "segment", name: "toolbar" },
831
- { kind: "segment", name: "styleControl" },
851
+ { kind: "segment", name: "cacheTimer" },
852
+ { kind: "segment", name: "block" },
853
+ { kind: "segment", name: "weekly" },
832
854
  ],
833
855
  },
834
856
  ],
@@ -18,7 +18,11 @@ const defaultLogger: WatcherLogger = (_level, message) => debug(message);
18
18
  // (git cache, config cache, ...). Scattered watchers across modules would leak
19
19
  // FDs and miss cleanup at shutdown.
20
20
 
21
- const DEBOUNCE_MS = 50;
21
+ // [LAW:one-source-of-truth] Exported: the invalidation-debounce floor is the
22
+ // single source consumers derive from (e.g. the load harness's git-churn
23
+ // interval must sit above it). Restating "50" anywhere else would let the two
24
+ // drift silently.
25
+ export const DEBOUNCE_MS = 50;
22
26
  const DEFAULT_MAX_WATCHERS = 128;
23
27
 
24
28
  // Absolute filesystem paths the consumer wants invalidation for.
@@ -188,6 +188,110 @@ function isRecord(v: unknown): v is Record<string, unknown> {
188
188
  return typeof v === "object" && v !== null && !Array.isArray(v);
189
189
  }
190
190
 
191
+ // [LAW:decomposition] The core git snapshot as a SINGLE `git status
192
+ // --porcelain=v2 --branch` yields it. That one invocation reports branch,
193
+ // short SHA, upstream, ahead/behind, and the full worktree status — so the
194
+ // prior fan-out (status -b + two rev-list + a branch-fallback + rev-parse HEAD
195
+ // + rev-parse @{u}, up to six spawns) collapses to one. Fewer spawns per cache
196
+ // miss is the whole point of brandon-daemon-perf-bb9.1; folding these also
197
+ // makes ahead/behind SHARE FATE with status (same subprocess) instead of being
198
+ // an independently-failable partial state ([LAW:types-are-the-program]).
199
+ interface CoreStatus {
200
+ branch: string;
201
+ status: "clean" | "dirty" | "conflicts";
202
+ workingTree: WorkingTree;
203
+ // Each an Outcome so "no upstream / unborn HEAD" (absent) stays distinct from
204
+ // a value — the same three-state contract every on-demand field carries.
205
+ aheadBehind: Outcome<AheadBehind>;
206
+ sha: Outcome<string>;
207
+ upstream: Outcome<string>;
208
+ }
209
+
210
+ // [LAW:effects-at-boundaries] Pure text→data: the subprocess (the effect) lives
211
+ // in getCoreAsync; this parses its stdout. Exported so the accept/reject shape
212
+ // table is unit-testable without spawning git.
213
+ //
214
+ // Porcelain v2 header lines (`# branch.<field> <value>`) carry branch/oid/
215
+ // upstream/ab; entry lines classify the worktree:
216
+ // `1 XY …` ordinary change → XY[0]=index, XY[1]=worktree ('.' = unmodified)
217
+ // `2 XY …` rename/copy → same XY columns
218
+ // `u …` unmerged → a conflict
219
+ // `? path` untracked
220
+ // `! path` ignored (never requested here; skipped)
221
+ // The `(initial)` oid (unborn HEAD) and `(detached)` head are git's sentinels
222
+ // for "no commit yet" and "detached" — mapped to absent-sha and the "detached"
223
+ // branch label respectively, matching the prior fallback-chain behavior.
224
+ export function parseStatusV2(stdout: string): CoreStatus {
225
+ let branch = "detached";
226
+ let sha: Outcome<string> = ABSENT;
227
+ let upstream: Outcome<string> = ABSENT;
228
+ let aheadBehind: Outcome<AheadBehind> = ABSENT;
229
+ let staged = 0;
230
+ let unstaged = 0;
231
+ let untracked = 0;
232
+ let conflicts = 0;
233
+
234
+ for (const line of stdout.split("\n")) {
235
+ if (!line) continue;
236
+
237
+ if (line.startsWith("# ")) {
238
+ const rest = line.slice(2);
239
+ if (rest.startsWith("branch.oid ")) {
240
+ const v = rest.slice("branch.oid ".length).trim();
241
+ // Fixed 7-char truncation is the display contract. `git rev-parse
242
+ // --short` auto-lengthens on collision, but re-spawning it here to
243
+ // recover that would undo this segment's whole point — one porcelain=v2
244
+ // read instead of a fan-out. The sha is display-only (never a lookup
245
+ // key), so a 7-char ambiguity in a >1M-object repo is cosmetic.
246
+ sha = v === "(initial)" ? ABSENT : ok(v.slice(0, 7));
247
+ } else if (rest.startsWith("branch.head ")) {
248
+ const v = rest.slice("branch.head ".length).trim();
249
+ branch = v === "(detached)" ? "detached" : v;
250
+ } else if (rest.startsWith("branch.upstream ")) {
251
+ upstream = ok(rest.slice("branch.upstream ".length).trim());
252
+ } else if (rest.startsWith("branch.ab ")) {
253
+ // Format is exactly "+<ahead> -<behind>"; a shape mismatch leaves
254
+ // aheadBehind absent rather than fabricating a count.
255
+ const m = rest
256
+ .slice("branch.ab ".length)
257
+ .trim()
258
+ .match(/^\+(\d+)\s+-(\d+)$/);
259
+ if (m) {
260
+ aheadBehind = ok({
261
+ ahead: parseInt(m[1]!, 10),
262
+ behind: parseInt(m[2]!, 10),
263
+ });
264
+ }
265
+ }
266
+ continue;
267
+ }
268
+
269
+ const kind = line[0];
270
+ if (kind === "1" || kind === "2") {
271
+ const xy = line.slice(2, 4);
272
+ if (xy[0] !== ".") staged++;
273
+ if (xy[1] !== ".") unstaged++;
274
+ } else if (kind === "u") {
275
+ conflicts++;
276
+ } else if (kind === "?") {
277
+ untracked++;
278
+ }
279
+ }
280
+
281
+ let status: "clean" | "dirty" | "conflicts" = "clean";
282
+ if (conflicts > 0) status = "conflicts";
283
+ else if (staged || unstaged || untracked) status = "dirty";
284
+
285
+ return {
286
+ branch,
287
+ status,
288
+ aheadBehind,
289
+ sha,
290
+ upstream,
291
+ workingTree: { staged, unstaged, untracked, conflicts },
292
+ };
293
+ }
294
+
191
295
  // `gh pr view --json number,state,url` → one JSON object. Only an OPEN PR is a
192
296
  // value; a MERGED/CLOSED PR for the branch is the domain's `absent`.
193
297
  export function parseGithubPr(stdout: string): Outcome<PullRequest> {
@@ -346,28 +450,27 @@ export class GitService {
346
450
  gitDir = foundGitRoot.value;
347
451
  }
348
452
 
349
- // branch/status are the core: without them there is no useful GitInfo,
350
- // so a failed core fetch fails the whole outcome rather than dressing
351
- // up as a clean repo on a fallback branch.
352
- const core = await this.getStatusWithBranchAsync(gitDir);
453
+ // branch/status/ahead-behind/sha/upstream are the core, and one
454
+ // `git status --porcelain=v2 --branch` yields them all: without branch and
455
+ // status there is no useful GitInfo, so a failed core fetch fails the whole
456
+ // outcome rather than dressing up as a clean repo on a fallback branch.
457
+ const core = await this.getCoreAsync(gitDir);
353
458
  if (core.kind !== "ok") return core;
354
- const aheadBehind = await this.getAheadBehindAsync(gitDir);
355
459
 
356
460
  const result: GitInfo = {
357
461
  branch: core.value.branch,
358
462
  status: core.value.status,
359
- aheadBehind,
463
+ aheadBehind: core.value.aheadBehind,
360
464
  };
361
465
 
362
- if (options.showWorkingTree) {
363
- result.workingTree = core.value.workingTree;
364
- }
466
+ // sha, upstream, and the worktree counts all rode in on the core call —
467
+ // attaching them here is a memory read, not another spawn.
468
+ if (options.showWorkingTree) result.workingTree = core.value.workingTree;
469
+ if (options.showSha) result.sha = core.value.sha;
470
+ if (options.showUpstream) result.upstream = core.value.upstream;
365
471
 
366
472
  // Heavy operations stay serial — each is an expensive git invocation and
367
473
  // running them one at a time bounds concurrent git load per fetch.
368
- if (options.showSha) {
369
- result.sha = await this.getShaAsync(gitDir);
370
- }
371
474
  if (options.showTag) {
372
475
  result.tag = await this.getNearestTagAsync(gitDir);
373
476
  }
@@ -378,13 +481,11 @@ export class GitService {
378
481
  // Light operations run in parallel. Helpers never reject — failure is a
379
482
  // value in the outcome — so plain Promise.all replaces the allSettled +
380
483
  // untyped resultMap machinery the swallowing design required.
381
- const [stashCount, upstream, repoName] = await Promise.all([
484
+ const [stashCount, repoName] = await Promise.all([
382
485
  options.showStashCount ? this.getStashCountAsync(gitDir) : undefined,
383
- options.showUpstream ? this.getUpstreamAsync(gitDir) : undefined,
384
486
  options.showRepoName ? this.getRepoNameAsync(gitDir) : undefined,
385
487
  ]);
386
488
  if (stashCount !== undefined) result.stashCount = stashCount;
387
- if (upstream !== undefined) result.upstream = upstream;
388
489
  if (repoName !== undefined) {
389
490
  result.repoName = repoName;
390
491
  result.isWorktree = isWorktreeDir;
@@ -397,20 +498,6 @@ export class GitService {
397
498
  return ok(result);
398
499
  }
399
500
 
400
- private async getShaAsync(workingDir: string): Promise<Outcome<string>> {
401
- // non-zero = no HEAD to resolve (empty repo) — a domain answer.
402
- return nonEmpty(
403
- classify(
404
- "git rev-parse HEAD",
405
- await this.execGitAsync(["rev-parse", "--short=7", "HEAD"], {
406
- cwd: workingDir,
407
- timeout: 2000,
408
- }),
409
- "absent",
410
- ),
411
- );
412
- }
413
-
414
501
  // [LAW:locality-or-seam] Public so the daemon-side provider can watch the
415
502
  // real HEAD/index files even for git worktrees. For a regular repo this is
416
503
  // `<workingDir>/.git`. For a worktree, `<workingDir>/.git` is a *file*
@@ -521,20 +608,6 @@ export class GitService {
521
608
  return ok(stashList ? stashList.split("\n").length : 0);
522
609
  }
523
610
 
524
- private async getUpstreamAsync(workingDir: string): Promise<Outcome<string>> {
525
- // non-zero = no upstream configured — the everyday domain answer.
526
- return nonEmpty(
527
- classify(
528
- "git rev-parse @{u}",
529
- await this.execGitAsync(["rev-parse", "--abbrev-ref", "@{u}"], {
530
- cwd: workingDir,
531
- timeout: 2000,
532
- }),
533
- "absent",
534
- ),
535
- );
536
- }
537
-
538
611
  private async getRepoNameAsync(workingDir: string): Promise<Outcome<string>> {
539
612
  const r = classify(
540
613
  "git config remote.origin.url",
@@ -647,17 +720,16 @@ export class GitService {
647
720
  }
648
721
  }
649
722
 
650
- private async getStatusWithBranchAsync(workingDir: string): Promise<
651
- Outcome<{
652
- branch: string;
653
- status: "clean" | "dirty" | "conflicts";
654
- workingTree: WorkingTree;
655
- }>
656
- > {
657
- debug(`[GIT-EXEC] Running git status in ${workingDir}`);
723
+ // [LAW:single-enforcer] The one core git read. `git status --porcelain=v2
724
+ // --branch` reports branch, short SHA, upstream, ahead/behind, and worktree
725
+ // status in a single subprocess — the fan-out this method replaces spawned up
726
+ // to six (status -b, two rev-list, a branch fallback, rev-parse HEAD, rev-parse
727
+ // @{u}). Parsing is delegated to the pure `parseStatusV2`.
728
+ private async getCoreAsync(workingDir: string): Promise<Outcome<CoreStatus>> {
729
+ debug(`[GIT-EXEC] Running git status --porcelain=v2 in ${workingDir}`);
658
730
  const r = classify(
659
- "git status --porcelain -b",
660
- await this.execGitAsync(["status", "--porcelain", "-b"], {
731
+ "git status --porcelain=v2 --branch",
732
+ await this.execGitAsync(["status", "--porcelain=v2", "--branch"], {
661
733
  cwd: workingDir,
662
734
  timeout: 2000,
663
735
  }),
@@ -666,124 +738,6 @@ export class GitService {
666
738
  "failed",
667
739
  );
668
740
  if (r.kind !== "ok") return r;
669
-
670
- const lines = r.value.split("\n");
671
-
672
- let branch: string | null = null;
673
- let status: "clean" | "dirty" | "conflicts" = "clean";
674
- let staged = 0;
675
- let unstaged = 0;
676
- let untracked = 0;
677
- let conflicts = 0;
678
-
679
- for (const line of lines) {
680
- if (!line) continue;
681
-
682
- if (line.startsWith("## ")) {
683
- const branchLine = line.substring(3);
684
- const branchMatch = branchLine.split("...")[0];
685
- if (branchMatch && branchMatch !== "HEAD (no branch)") {
686
- branch = branchMatch;
687
- }
688
- continue;
689
- }
690
-
691
- if (line.length >= 2) {
692
- const indexStatus = line.charAt(0);
693
- const worktreeStatus = line.charAt(1);
694
-
695
- if (indexStatus === "?" && worktreeStatus === "?") {
696
- untracked++;
697
- if (status === "clean") status = "dirty";
698
- continue;
699
- }
700
-
701
- const statusPair = indexStatus + worktreeStatus;
702
- if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(statusPair)) {
703
- conflicts++;
704
- status = "conflicts";
705
- continue;
706
- }
707
-
708
- if (indexStatus !== " " && indexStatus !== "?") {
709
- staged++;
710
- if (status === "clean") status = "dirty";
711
- }
712
- if (worktreeStatus !== " " && worktreeStatus !== "?") {
713
- unstaged++;
714
- if (status === "clean") status = "dirty";
715
- }
716
- }
717
- }
718
-
719
- if (branch === null) {
720
- const fallback = await this.getFallbackBranch(workingDir);
721
- // A transport failure resolving the branch fails the core: rendering
722
- // a fake "detached" for a repo whose branch merely couldn't be read
723
- // would be the same meaning-erasure this type exists to forbid.
724
- if (fallback.kind === "failed") return fallback;
725
- branch = fallback.kind === "ok" ? fallback.value : "detached";
726
- }
727
-
728
- return ok({
729
- branch,
730
- status,
731
- workingTree: { staged, unstaged, untracked, conflicts },
732
- });
733
- }
734
-
735
- private async getFallbackBranch(
736
- workingDir: string,
737
- ): Promise<Outcome<string>> {
738
- // Both commands answer "detached" with a non-zero exit (symbolic-ref) or
739
- // empty output (show-current) — `absent` means genuinely detached.
740
- const primary = nonEmpty(
741
- classify(
742
- "git branch --show-current",
743
- await this.execGitAsync(["branch", "--show-current"], {
744
- cwd: workingDir,
745
- timeout: 2000,
746
- }),
747
- "absent",
748
- ),
749
- );
750
- if (primary.kind !== "absent") return primary;
751
- return nonEmpty(
752
- classify(
753
- "git symbolic-ref HEAD",
754
- await this.execGitAsync(["symbolic-ref", "--short", "HEAD"], {
755
- cwd: workingDir,
756
- timeout: 2000,
757
- }),
758
- "absent",
759
- ),
760
- );
761
- }
762
-
763
- private async getAheadBehindAsync(
764
- workingDir: string,
765
- ): Promise<Outcome<AheadBehind>> {
766
- debug(`[GIT-EXEC] Running git ahead/behind in ${workingDir}`);
767
- const [aheadResult, behindResult] = await Promise.all([
768
- this.execGitAsync(["rev-list", "--count", "@{u}..HEAD"], {
769
- cwd: workingDir,
770
- timeout: 2000,
771
- }),
772
- this.execGitAsync(["rev-list", "--count", "HEAD..@{u}"], {
773
- cwd: workingDir,
774
- timeout: 2000,
775
- }),
776
- ]);
777
- // non-zero = no upstream to compare against — the domain answer for any
778
- // local-only branch, distinct from a transport failure.
779
- const ahead = classify("git rev-list @{u}..HEAD", aheadResult, "absent");
780
- const behind = classify("git rev-list HEAD..@{u}", behindResult, "absent");
781
- if (ahead.kind === "failed") return ahead;
782
- if (behind.kind === "failed") return behind;
783
- if (ahead.kind === "absent" || behind.kind === "absent") return ABSENT;
784
- return ok({
785
- ahead: parseInt(ahead.value.trim()) || 0,
786
- behind: parseInt(behind.value.trim()) || 0,
787
- });
741
+ return ok(parseStatusV2(r.value));
788
742
  }
789
743
  }