@promptctl/cc-candybar 1.5.0 → 1.7.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.
@@ -16,6 +16,17 @@ export interface AheadBehind {
16
16
  behind: number;
17
17
  }
18
18
 
19
+ // [LAW:types-are-the-program] The branch's open PR/MR as the forge reports it.
20
+ // `number` and `url` are the click target; `state` is the forge's status string
21
+ // (GitHub "OPEN", GitLab "opened") — carried so a consumer can color/label it,
22
+ // though resolvePullRequest only ever returns a PR whose state is open (a
23
+ // merged/closed PR for the branch is the domain's `absent`, not a value).
24
+ export interface PullRequest {
25
+ number: number;
26
+ state: string;
27
+ url: string;
28
+ }
29
+
19
30
  // [LAW:types-are-the-program] Every on-demand field is an Outcome, so "this
20
31
  // value is unknown because the fetch failed" is representable distinct from
21
32
  // a real 0/""/basename — the states the old catch-and-substitute blocks
@@ -37,6 +48,13 @@ export interface GitInfo {
37
48
  upstream?: Outcome<string>;
38
49
  repoName?: Outcome<string>;
39
50
  isWorktree?: boolean;
51
+ // [LAW:no-silent-failure] The forge lookup's three outcomes are all kept
52
+ // distinct here: `ok` is an open PR, `absent` is "this branch has none / no
53
+ // forge / no forge CLI", `failed` is "the forge was asked but couldn't
54
+ // answer" (auth, network, API error). The render boundary surfaces `failed`
55
+ // as a VISIBLE marker — collapsing it to `absent` would make a transient
56
+ // outage look like the PR vanished. Undefined = `showPullRequest` was off.
57
+ pullRequest?: Outcome<PullRequest>;
40
58
  }
41
59
 
42
60
  // [LAW:one-source-of-truth] The one shape of getGitInfo's `show*` toggles. Each
@@ -53,6 +71,11 @@ export interface GitInfoOptions {
53
71
  showStashCount?: boolean;
54
72
  showUpstream?: boolean;
55
73
  showRepoName?: boolean;
74
+ // Opts into the forge (gh/glab) PR/MR lookup — a network call, so it is the
75
+ // one option whose fetch the daemon caches on a longer, independent TTL than
76
+ // the rest of GitInfo (see src/daemon/cache/git.ts). Never resolved by the
77
+ // inner GitService's computeGitInfo; the cache layer owns the lookup+cache.
78
+ showPullRequest?: boolean;
56
79
  }
57
80
 
58
81
  // [LAW:dataflow-not-control-flow] One classifier for every git invocation.
@@ -89,6 +112,134 @@ function nonEmpty(o: Outcome<string>): Outcome<string> {
89
112
  return v ? ok(v) : ABSENT;
90
113
  }
91
114
 
115
+ // [LAW:one-type-per-behavior] `gh` and `glab` are two instances of one act:
116
+ // "ask a forge CLI for the branch's PR, fold the typed launch result into an
117
+ // Outcome<PullRequest>." The accept/reject shape table is identical across
118
+ // both — only the no-PR stderr signature and the JSON field names differ — so
119
+ // the classification lives here once and each forge supplies its own
120
+ // (noPrPattern, parse) as data.
121
+ //
122
+ // [LAW:no-silent-failure] The full shape table, enumerated so no input leaks:
123
+ // ok + parse ok (open PR) → ok (the value)
124
+ // ok + parse ok (not open) → absent (branch's PR is done)
125
+ // ok + parse fails → failed (forge answered garbage)
126
+ // non-zero + no-PR stderr → absent (genuine "none for branch")
127
+ // spawn-error ENOENT (no CLI) → absent (no forge integration)
128
+ // spawn-error other (EACCES, …) → failed (CLI present but unlaunchable)
129
+ // non-zero (auth/net/not-a-repo) → failed (forge couldn't answer)
130
+ // timeout / signal / rate-limited → failed (forge couldn't answer)
131
+ export type ForgeName = "github" | "gitlab";
132
+
133
+ // [LAW:types-are-the-program] Extract the host from a git remote, handling the
134
+ // two shapes git uses: scp-like `[user@]host:path` and URL `scheme://[user@]
135
+ // host[:port]/path`. The URL form is checked first — its `host` in a scp regex
136
+ // would mis-capture the scheme (`https` before `://`). Returns null for an
137
+ // unrecognized shape (local path, unknown syntax).
138
+ function remoteHost(remoteUrl: string): string | null {
139
+ const url = remoteUrl.trim();
140
+ const proto = url.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i);
141
+ if (proto) return proto[1]!.toLowerCase();
142
+ const scp = url.match(/^(?:[^@/]+@)?([^/:]+):/);
143
+ if (scp) return scp[1]!.toLowerCase();
144
+ return null;
145
+ }
146
+
147
+ // [LAW:types-are-the-program] Branch on the HOST, not a substring of the whole
148
+ // URL — a non-GitLab remote whose path merely contains "gitlab" (a repo named
149
+ // `gitlab`) must not dispatch to glab. Self-hosted GitLab is detected by a
150
+ // `gitlab.`-prefixed host label (gitlab.example.com); a GitLab on an arbitrary
151
+ // hostname is undetectable here and falls through to null (absent), same as
152
+ // GitHub Enterprise on a custom domain.
153
+ export function detectForge(remoteUrl: string): ForgeName | null {
154
+ const host = remoteHost(remoteUrl);
155
+ if (!host) return null;
156
+ if (host === "github.com" || host.endsWith(".github.com")) return "github";
157
+ if (/(^|\.)gitlab\./.test(host)) return "gitlab";
158
+ return null;
159
+ }
160
+
161
+ export function classifyForgePr(
162
+ label: string,
163
+ result: LaunchResult,
164
+ noPrPattern: RegExp,
165
+ parse: (stdout: string) => Outcome<PullRequest>,
166
+ ): Outcome<PullRequest> {
167
+ if (result.ok) return parse(result.stdout);
168
+ // ENOENT (no forge CLI on PATH) is a static configuration absence, not a
169
+ // transient lookup failure — it never showed a PR, so showing nothing costs
170
+ // nothing. [LAW:no-silent-failure] Every OTHER spawn failure (EACCES,
171
+ // resource limits) means the CLI is present but could not launch — a real
172
+ // failure that must stay visible, so it falls through to the `failed` path.
173
+ if (result.reason === "spawn-error" && /ENOENT/i.test(result.error ?? ""))
174
+ return ABSENT;
175
+ if (result.reason === "non-zero" && noPrPattern.test(result.stderr))
176
+ return ABSENT;
177
+ const detail = [
178
+ result.reason,
179
+ result.exitCode != null ? `exit ${result.exitCode}` : null,
180
+ result.error ?? firstLine(result.stderr),
181
+ ]
182
+ .filter(Boolean)
183
+ .join(", ");
184
+ return failed(`${label}: ${detail}`);
185
+ }
186
+
187
+ function isRecord(v: unknown): v is Record<string, unknown> {
188
+ return typeof v === "object" && v !== null && !Array.isArray(v);
189
+ }
190
+
191
+ // `gh pr view --json number,state,url` → one JSON object. Only an OPEN PR is a
192
+ // value; a MERGED/CLOSED PR for the branch is the domain's `absent`.
193
+ export function parseGithubPr(stdout: string): Outcome<PullRequest> {
194
+ let json: unknown;
195
+ try {
196
+ json = JSON.parse(stdout);
197
+ } catch (e) {
198
+ return failed(
199
+ `gh pr view: unparseable JSON (${e instanceof Error ? e.message : String(e)})`,
200
+ );
201
+ }
202
+ if (!isRecord(json)) return failed("gh pr view: JSON is not an object");
203
+ const { number, state, url } = json;
204
+ if (
205
+ typeof number !== "number" ||
206
+ typeof state !== "string" ||
207
+ typeof url !== "string"
208
+ ) {
209
+ return failed("gh pr view: missing number/state/url");
210
+ }
211
+ if (state.toUpperCase() !== "OPEN") return ABSENT;
212
+ return ok({ number, state, url });
213
+ }
214
+
215
+ // `glab mr view --output json` → one JSON object (iid / state / web_url). State
216
+ // "opened" is the open MR; anything else is `absent`. NOTE: verified against
217
+ // glab's documented JSON shape, not runtime-exercised here (glab not installed
218
+ // on the dev machine) — the github path is the runtime-verified one.
219
+ export function parseGitlabMr(stdout: string): Outcome<PullRequest> {
220
+ let json: unknown;
221
+ try {
222
+ json = JSON.parse(stdout);
223
+ } catch (e) {
224
+ return failed(
225
+ `glab mr view: unparseable JSON (${e instanceof Error ? e.message : String(e)})`,
226
+ );
227
+ }
228
+ if (!isRecord(json)) return failed("glab mr view: JSON is not an object");
229
+ const iid = json.iid;
230
+ const state = json.state;
231
+ const url = json.web_url;
232
+ if (
233
+ typeof iid !== "number" ||
234
+ typeof state !== "string" ||
235
+ typeof url !== "string"
236
+ ) {
237
+ return failed("glab mr view: missing iid/state/web_url");
238
+ }
239
+ if (state.toLowerCase() !== "opened") return ABSENT;
240
+ return ok({ number: iid, state, url });
241
+ }
242
+
92
243
  export class GitService {
93
244
  private isGitRepo(workingDir: string): boolean {
94
245
  try {
@@ -407,6 +558,83 @@ export class GitService {
407
558
  return ok(match?.[1] || path.basename(workingDir));
408
559
  }
409
560
 
561
+ // [LAW:locality-or-seam] Public so the daemon's GitDataProvider can read the
562
+ // remote to fold into its PR cache key (the PR value depends on the remote;
563
+ // a re-pointed origin must be a new key). Raw origin URL (unparsed) — the
564
+ // forge detector reads the host from it. `config --get` exits 1 when unset →
565
+ // `absent` (no remote, hence no forge PR concept), distinct from a failure.
566
+ async getRemoteOriginUrl(workingDir: string): Promise<Outcome<string>> {
567
+ return nonEmpty(
568
+ classify(
569
+ "git config remote.origin.url",
570
+ await this.execGitAsync(["config", "--get", "remote.origin.url"], {
571
+ cwd: workingDir,
572
+ timeout: 2000,
573
+ }),
574
+ "absent",
575
+ ),
576
+ );
577
+ }
578
+
579
+ // [LAW:single-enforcer] One boundary for forge-CLI spawns. Mirrors
580
+ // execGitAsync but carries the "forge" launch category and a longer timeout
581
+ // (this is a network call, not a local git read). Returns the typed
582
+ // LaunchResult so classifyForgePr maps every termination cause to an Outcome.
583
+ private async execForgeAsync(
584
+ bin: string,
585
+ args: readonly string[],
586
+ options: { cwd: string; timeout: number },
587
+ ): Promise<LaunchResult> {
588
+ return launch({
589
+ bin,
590
+ args: [...args],
591
+ cwd: options.cwd,
592
+ env: { ...process.env },
593
+ timeoutMs: options.timeout,
594
+ category: "forge",
595
+ });
596
+ }
597
+
598
+ // [LAW:effects-at-boundaries] Resolve the branch's open PR/MR via the forge
599
+ // CLI. Pure dispatch over a remote the CALLER has already read: pick the
600
+ // forge by host, run its CLI, fold the launch result into an Outcome. The
601
+ // remote is a parameter (not read here) so the cache layer can fold it into
602
+ // its key in the same read — no caching here; the daemon's GitDataProvider
603
+ // owns the PR cache + TTL (a network resource wants a longer, independent
604
+ // lifecycle than local git state). `absent` when the host is no recognized
605
+ // forge; the CLI dispatch then classifies the rest.
606
+ async resolvePullRequest(
607
+ workingDir: string,
608
+ remoteUrl: string,
609
+ ): Promise<Outcome<PullRequest>> {
610
+ const forge = detectForge(remoteUrl);
611
+ if (forge === "github") {
612
+ return classifyForgePr(
613
+ "gh pr view",
614
+ await this.execForgeAsync(
615
+ "gh",
616
+ ["pr", "view", "--json", "number,state,url"],
617
+ { cwd: workingDir, timeout: 5000 },
618
+ ),
619
+ /no (open )?pull requests? found/i,
620
+ parseGithubPr,
621
+ );
622
+ }
623
+ if (forge === "gitlab") {
624
+ return classifyForgePr(
625
+ "glab mr view",
626
+ await this.execForgeAsync("glab", ["mr", "view", "--output", "json"], {
627
+ cwd: workingDir,
628
+ timeout: 5000,
629
+ }),
630
+ /no (open )?merge requests? (found|available)/i,
631
+ parseGitlabMr,
632
+ );
633
+ }
634
+ // Recognized neither host → no forge integration for this remote.
635
+ return ABSENT;
636
+ }
637
+
410
638
  private isWorktree(workingDir: string): boolean {
411
639
  try {
412
640
  const gitDir = path.join(workingDir, ".git");
@@ -20,7 +20,8 @@
20
20
  // `dict` lets a helper take multiple named inputs through its one dot arg.
21
21
  // • richTextFuncs: bold, italic, red, green, … (styling from rich-js).
22
22
  // • paletteFuncs (when resolver provided): primary, accent, palette, paletteOver, auto.
23
- // • ccCandybarFuncs: basename, dirname, int, string, bool, urlEncode.
23
+ // • ccCandybarFuncs: basename, dirname, int, string, bool, urlEncode,
24
+ // themes, styles, sparkline.
24
25
  // • formatterFuncs: minutesUntilReset (clock-reading numeric primitive),
25
26
  // formatInteger, round, formatModelName, shortenModelName. (The cost/token/
26
27
  // budget AND duration/time-remaining formatters moved to DSL helper templates
@@ -17,6 +17,7 @@ import {
17
17
  shortenModelName,
18
18
  } from "../utils/formatters.js";
19
19
  import { listResolvablePaletteNames, STRIP_STYLES } from "../themes/policy.js";
20
+ import { renderSparkline, parseSeries } from "./sparkline.js";
20
21
 
21
22
  // [LAW:one-source-of-truth] The DSL `themes()` and `styles()` bindings
22
23
  // project the SAME canonical sources the set-state validator consults
@@ -128,6 +129,22 @@ export function ccCandybarFuncs(): FuncMap {
128
129
  fn: () => STYLES_LIST,
129
130
  argTypes: [],
130
131
  },
132
+
133
+ // [LAW:effects-at-boundaries] Pure trend renderer: a numeric series (the
134
+ // daemon-owned ring, projected through the payload as a delimited string)
135
+ // becomes a unicode mini-graph. The series crosses the scalar var-system
136
+ // seam as a string, so the FuncMap slot is "string"; `parseSeries` decodes
137
+ // it and `renderSparkline` draws it — neither accumulates state. The
138
+ // optional trailing "int" slot caps the glyph count to fit a cell (the
139
+ // evaluator validates only supplied args, so `{{ sparkline .series }}` and
140
+ // `{{ sparkline .series 24 }}` are both well-typed). Returns a bare string;
141
+ // the engine lifts it to RichText so the segment's fg/bg palette colors the
142
+ // whole graph — no per-glyph color math here.
143
+ sparkline: {
144
+ fn: (series: string, width?: number) =>
145
+ renderSparkline(parseSeries(series), width),
146
+ argTypes: ["string", "int"],
147
+ },
131
148
  };
132
149
  }
133
150
 
@@ -0,0 +1,79 @@
1
+ // [LAW:effects-at-boundaries] A sparkline is a PURE projection: a numeric
2
+ // series in, a string of block glyphs out. It reads no clock, touches no
3
+ // store, accumulates nothing — any history it draws is owned by the daemon
4
+ // cache and handed in as data (cf. session-usage-store's burn-rate ring).
5
+ // [LAW:one-source-of-truth] The renderer operates on number[] (the real
6
+ // domain); the var-system can only carry a scalar across the payload→template
7
+ // seam, so `parseSeries` decodes the delimited string form at that one edge —
8
+ // the wire shape and the domain shape have a single, tested conversion.
9
+
10
+ // The eight-level block ramp, lowest→highest. Index into this is the only
11
+ // place a value's normalized height becomes a glyph.
12
+ export const SPARK_LEVELS = [
13
+ "▁", // ▁
14
+ "▂", // ▂
15
+ "▃", // ▃
16
+ "▄", // ▄
17
+ "▅", // ▅
18
+ "▆", // ▆
19
+ "▇", // ▇
20
+ "█", // █
21
+ ] as const;
22
+
23
+ const LEVELS = SPARK_LEVELS.length;
24
+
25
+ // Render a numeric series as a unicode mini-graph.
26
+ //
27
+ // `width` caps the glyph count to fit a cell: the MOST RECENT `width` samples
28
+ // are shown (tail slice), so a fixed-width cell tracks the live tail of a
29
+ // growing series. Omitted → every sample renders; `width <= 0` → empty.
30
+ //
31
+ // Heights are RELATIVE to the rendered window's own min/max — a sparkline shows
32
+ // shape, never absolute magnitude, so the full ramp is always used when the
33
+ // window varies. [LAW:dataflow-not-control-flow] The mapping is one unconditional
34
+ // fold over the values; the only data-driven value is `range`, and a flat
35
+ // window (range === 0) falls to the lowest tier by the same formula's limit
36
+ // (height-above-min is 0 for every sample), not a special-cased branch.
37
+ export function renderSparkline(values: number[], width?: number): string {
38
+ const window =
39
+ width === undefined ? values : width <= 0 ? [] : values.slice(-width);
40
+ if (window.length === 0) return "";
41
+
42
+ let min = window[0]!;
43
+ let max = window[0]!;
44
+ for (const v of window) {
45
+ if (v < min) min = v;
46
+ if (v > max) max = v;
47
+ }
48
+ const range = max - min;
49
+
50
+ let out = "";
51
+ for (const v of window) {
52
+ // range === 0 ⇒ every sample sits at its own min ⇒ height 0 ⇒ lowest tier.
53
+ const idx =
54
+ range === 0 ? 0 : Math.round(((v - min) / range) * (LEVELS - 1));
55
+ out += SPARK_LEVELS[idx]!;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // Decode the delimited string a series travels as across the scalar var-system
61
+ // seam into the number[] the renderer consumes. Empty / blank tokens are the
62
+ // genuine "no sample" form (an empty payload field is ""), so they drop; a
63
+ // non-empty, non-numeric token is malformed input and fails LOUDLY rather than
64
+ // being silently skipped into a wrong-shaped graph. [LAW:no-silent-failure]
65
+ export function parseSeries(s: string): number[] {
66
+ const out: number[] = [];
67
+ for (const tok of s.split(",")) {
68
+ const t = tok.trim();
69
+ if (t === "") continue;
70
+ const n = Number(t);
71
+ if (!Number.isFinite(n)) {
72
+ throw new TypeError(
73
+ `sparkline: non-numeric series element ${JSON.stringify(tok)}`,
74
+ );
75
+ }
76
+ out.push(n);
77
+ }
78
+ return out;
79
+ }